fix: improve sharing recovery and UI feedback

## Problem and outcome / 问题与结果

Browser sharing can remain connected to a local encoding transport that never becomes usable, leaving viewers without frames. This phase bounds that startup at eight seconds and restores ordinary encoding through the existing failure path. It also keeps ready App services running after a browser-open failure, preserves user presentation/playback choices, and aligns room feedback, illustrated hints, metrics and documentation.

部分环境下,本机编码连接一直无法建立会让观众收不到画面。本轮在八秒后进入现有降级流程,同时修复客户端启动移交、播放状态和前端反馈的一致性问题,并更新官网与中英文教程。

Closes #399.

Related: #396. The release contains relevant startup/error-handling repairs, but the reporting machine has not been retested. Keep this issue open for a current-build result. #401 (optional Windows capture-border hiding) remains deferred.

Release / 发布版本:**v1.3.1**.

## Release notes

### 中文

- 客户端记住上次确认的启动模式;启动移交保留已保存的语言、纯视觉模式和主题选择。
- 系统浏览器未能自动打开时,客户端继续运行,并在终端保留页面地址、重试入口和具体错误。
- 改善浏览器共享画面的启动兜底,避免 App 媒体因短暂信令中断被误判为失败。
- 音轨变化保持现有视频与观众的本机暂停状态;连接恢复和暂停恢复不再重复播放电视开机动画。
- 修复昵称编辑及长昵称查看,减少重复的角色提示,统一人物选择和连接状态的区别。
- 完善房间准入、密码和邀请说明;复制反馈对应当前链接及当前操作,避免迟到结果覆盖新反馈。
- 修正采集参数、统计单位与采样区间的展示,未知参数不再用配置上限代替。
- 统一提示漫画、状态图标与动效,改善选源器还焦、键盘操作、剧场布局和触控目标尺寸。
- 服务端默认支持 20 位观众,可在配置中设为 1–20 位,房主不占观众名额。
- 更新官网、教程入口和中英文说明,清理过时预览及失用代码。

兼容说明:使用旧版 App 连接更新后的站点时,站点会优先保留自己的显示偏好。如需更改语言或主题,请在进入后的页面调整。

### English

- Remember the last confirmed App launch mode and preserve saved language, visual-mode and theme choices during launch handoff.
- Keep the App running if the system browser fails to open, with the page address, retry action and error details available in the terminal.
- Improve the browser sharing startup fallback and avoid treating brief signaling outages as App media failures.
- Keep existing video and local pause state when audio tracks change. Reconnecting and resuming no longer replay the television startup animation over the picture.
- Fix nickname editing and full-name access, remove redundant role hints, and distinguish participant selection from connection state.
- Clarify room access, passwords and invitations. Copy feedback now belongs to the current link and operation, so late results cannot replace newer feedback.
- Correct capture details, metric units and observation intervals. Unknown capture values are no longer replaced with configured ceilings.
- Align illustrated hints, status icons and motion. Improve source-picker focus, keyboard operation, theater layout and touch targets.
- Default the Server to 20 viewers, configurable from 1 to 20. The host does not count toward that limit.
- Update the website, guide links and bilingual instructions, and remove obsolete previews and unused code.

Compatibility: when an older App connects to an updated Site, the Site keeps its saved presentation preferences. Change language or theme on the destination page when using an older launcher.

## Verification / 验证

Non-publishing [CI candidate run 34919320511](https://github.com/TNTcraftHIM/Piik/actions/runs/34919320511) passed Go core, race, Web checks, Server/container verification and all three native App package checks at source 188376e8. Subsequent commits only update deployment documentation, status and TODO; documentation/hygiene checks passed. Product inputs are unchanged.

- `npm run check`: TypeScript, 64 test files / 854 tests, and the Web production build passed.
- `npm run check:client-core`: final Go source passed tests, formatting, vet and core target builds. Windows capture compilation and geometry checks passed earlier in this phase; later edits did not change native capture.
- Website build, documentation checks, repository hygiene and diff checks passed. Existing documentation-size and bundle-size warnings remain.
- Actual browser flows covered sharing, source changes, pause/resume, late audio, brief signaling loss, invitation/copy feedback, entry preferences, source-picker focus, and narrow layouts.
- A local three-viewer P2P relay check covered quality-report expiry, recovery and cleanup without interrupting video. A controlled browser-helper failure returning `0xc0000005` left the real App launcher and Local service available.
- Homepage checks covered Chinese/English, light/dark preferences and inline-film open/close after removing the separate-page entry.

Limits: no new reporter-machine confirmation for #396 or the Windows URL-handler crash; physical Safari/mobile, macOS/Linux capture and wider public-network/endurance coverage remain deferred. Formal release artifacts will be built from the approved squash-merge revision. Hero-loop controls and video-adjacent tooltip placement remain open design items in TODO.

## Impact / 影响

Compatible patch; signaling, Native control and capture contracts are unchanged. Older App launch handoff keeps the updated Site's saved presentation preferences. The Server's default viewer limit is corrected to 20; an explicit 1–20 configuration remains valid.

Service updates retain persisted room authority but restart active media sessions. Deploy the verified immutable Server artifact through the existing bounded health/recovery process. Public Demo remains open and P2P-only; deployment does not enable SFU or change infrastructure/configuration.
This commit is contained in:
AAA authored and GitHub committed 2026-09-15 10:13:29 +08:00
1 parent 8effc8e8f4
commit 799d6efb4a
126 files changed
+4056 -2597

No files matched your search

+3 -2
View File
@@ -8,14 +8,15 @@ PUBLIC_BASE_URL=http://localhost:8787
ALLOWED_ORIGINS=http://localhost:8787
# Optional site access password in every environment. Unset or empty allows
# entry without a site password; room ownership and invitations still apply.
# A non-empty value must contain 8-128 visible ASCII bytes (0x21-0x7e).
# Non-empty values are matched exactly; spaces and Unicode are supported.
SITE_ACCESS_PASSWORD=
# Rooms persist in rooms.sqlite under the working directory by default (also
# when blank). Set :memory: explicitly for process-memory rooms. Deployments
# should use an access-restricted absolute path outside the release directory,
# preserving the existing database and its writable parent across updates.
ROOM_DATABASE_PATH=
MAX_VIEWERS_PER_ROOM=8
# Viewers per room, excluding the Host. Valid range: 1..20; default: 20.
MAX_VIEWERS_PER_ROOM=20
# Bounded standard-WebRTC relay/SFU controller is active for every room.
# One steady outbound media-copy capacity for every non-server endpoint.
# A peer child or Host SFU publication consumes one copy. Valid values: 1, 2, 3.
+3 -3
View File
@@ -96,9 +96,9 @@ Confirm runner inclusion when adding or moving a test; a file's presence alone
does not establish coverage.
- `npm run check` owns deterministic Web type-check, unit, and build acceptance.
- `npm run check:client` owns Go formatting, unit tests, vet, three-platform
builds, and the Windows capture compile/probe when run on Windows. The server
core is Go, so its acceptance runs here. It builds the Vite client first when
- `npm run check:client` owns Go formatting, unit tests, vet, Windows/Linux
builds, Darwin builds on macOS, and the current platform's capture compile/probe.
The server core is Go, so its acceptance runs here. It builds the Vite client first when
that output is missing, because both binaries embed it. CI invokes these same
package commands rather than rebuilding their steps in YAML.
- `npm run check:client-race` checks port mapping, media-edge ownership and their
+6 -5
View File
@@ -5,7 +5,7 @@
<a href="https://piik.tv">Website</a> ·
<a href="https://github.com/TNTcraftHIM/Piik/releases">Download</a> ·
<a href="https://gitee.com/TNTcraftHIM/Piik/releases">Gitee mirror</a> ·
<a href="https://demo.piik.tv">Try the demo</a> ·
<a href="https://demo.piik.tv">Use online</a> ·
<a href="./docs/README.md">Documentation</a>
</p>
<p align="center">
@@ -50,11 +50,12 @@ watch in their browsers.
**To watch:** open the invitation your friend sends you. Select **Play** if the
picture does not start automatically.
**To share:**
**To share:** download **Piik App** to get started. To share without installing,
you can also create a room in the online version.
| Start here | What you need to do |
| --- | --- |
| [Try the online demo](https://demo.piik.tv) | Open it in a desktop browser and start sharing. |
| [Use Piik online](https://demo.piik.tv) | Open it in a desktop browser to create a room and share. |
| [Download Piik App](https://piik.tv/#download) | Extract and open the App, choose **Public invite**, then **Open Piik** to create a temporary room. |
| Use an existing Piik site | Open the address your friend or administrator provides. [Hosting your own](./docs/operations/self-hosting.md) is an advanced option. |
@@ -76,13 +77,13 @@ Download a ZIP beginning with **`piik-app`**. The filename also identifies the p
| `darwin-arm64` | Apple silicon; native capture requires macOS 13+ |
| `linux-amd64` | Linux x64 |
[**Full setup guide →**](./docs/guide/getting-started.md) · [**Open the demo →**](https://demo.piik.tv)
[**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).
P2P-only modes, including the App's temporary public
link and the demo, may not connect on restrictive networks.
link and the online version, may not connect on restrictive networks.
## Self-hosting
+5 -5
View File
@@ -5,7 +5,7 @@
<a href="https://piik.tv/?lang=zh-CN">官网</a> ·
<a href="https://github.com/TNTcraftHIM/Piik/releases">下载</a> ·
<a href="https://gitee.com/TNTcraftHIM/Piik/releases">Gitee 镜像</a> ·
<a href="https://demo.piik.tv">在线演示</a> ·
<a href="https://demo.piik.tv">在线版</a> ·
<a href="./docs/README.md">文档</a>
</p>
<p align="center">
@@ -48,11 +48,11 @@ Piik 是一个免费开源的屏幕共享工具,适合游戏围观、一起看
**观看分享:** 打开朋友发来的邀请链接。画面没有自动播放时,点击**播放**即可。
**分享画面:**
**分享画面:** 推荐下载 **Piik App**。想免安装,也可以打开在线版直接创建房间。
| 从这里开始 | 需要做什么 |
| --- | --- |
| [体验在线 Demo](https://demo.piik.tv) | 在电脑浏览器打开,开始分享。 |
| [使用在线版](https://demo.piik.tv) | 在电脑浏览器打开,直接创建房间并分享。 |
| [下载 Piik App](https://piik.tv/?lang=zh-CN#download) | 解压并启动客户端,选择**公网邀请**,点击**进入 Piik**,创建临时房间。 |
| 使用已有 Piik 站点 | 打开朋友或管理员提供的地址。[自行部署](./docs/operations/self-hosting.zh-CN.md)属于进阶操作。 |
@@ -73,11 +73,11 @@ Piik 默认跟随系统语言,中文系统显示简体中文,其他语言显
| `darwin-arm64` | Apple 芯片;原生采集需 macOS 13 及以上 |
| `linux-amd64` | Linux x64 |
[**完整使用教程 →**](./docs/guide/getting-started.zh-CN.md) · [**打开在线演示 →**](https://demo.piik.tv)
[**完整使用教程 →**](./docs/guide/getting-started.zh-CN.md) · [**打开在线版 →**](https://demo.piik.tv)
浏览器采集需要 HTTPS 或 `localhost`。目前主要测试 Windows 客户端和桌面浏览器分享。
macOS 和 Linux 客户端尚未经过实机测试,欢迎有设备的朋友试用并[反馈问题](https://github.com/TNTcraftHIM/Piik/issues)。
客户端公网邀请和演示站使用纯 P2P 连接,
客户端公网邀请和在线版使用纯 P2P 连接,
在受限网络下可能无法连通。
## 自行部署
+39 -15
View File
@@ -1,5 +1,7 @@
# Piik App
English · [简体中文](./README.zh-CN.md) · [Getting started](../../docs/guide/getting-started.md)
Piik App provides local rooms and native screen capture through the Piik web
interface in your system Browser.
@@ -11,6 +13,9 @@ Linux, or `Piik App.app` on macOS. The launcher opens in the system Browser.
Linux native capture uses the system dependencies described
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
[feedback](https://github.com/TNTcraftHIM/Piik/issues) are welcome. macOS native
@@ -22,8 +27,11 @@ publication are separate steps in [deployment](../../docs/deployment.md).
- With no mode argument, the App opens a small launcher in the system
Browser. It selects Local, a temporary public HTTPS invitation, or a saved
Site, then enters the normal Host page.
- The launcher remembers the Site address but keeps the per-run room source
separate. The App RPC starts before this choice and accepts the saved Site
- The launcher remembers the last mode confirmed with **Open Piik** and the saved
Site address. Without a saved mode, it selects Site when an address is saved,
otherwise Public invite; it waits for confirmation before starting.
[App configuration](../../docs/reference/configuration.md#piik-app-configuration)
owns the saved settings. The App RPC starts before this choice and accepts the saved Site
alongside the Local Host origin. An App-opened Site remembers the opt-in in that Browser
origin; later manually opened Host pages may reuse the running App.
- The `--site`, `--local`, and `--link` flags select a mode for CI and development.
@@ -80,7 +88,7 @@ persistent quality evidence and route replacement. See
[media quality](../../docs/product/media-quality.md) for the implemented behavior
and [status](../../docs/status.md) for its acceptance limits.
The App configuration keeps an optional Local site-access password. Leave it
The App configuration keeps an optional Local site passphrase. Leave it
blank for an open Local site, or enter a password in the launcher before
starting. When present, the App passes it to its own
Host page in a URL fragment; the page uses the existing SiteAccess endpoint and
@@ -138,15 +146,32 @@ log locations, export commands, retention and privacy boundaries.
## Development
The binary embeds the Browser assets, so build them before running it:
Use the Node/npm/Go versions in [Run from source](../../docs/README.md#run-from-source).
The binary embeds the Browser assets, so build them from the repository root first:
```sh
npm ci
npm run build:client
go test ./...
go vet ./...
```
On Linux or macOS:
```sh
go run ./cmd/piik-app
```
On Windows, build and run from a stable executable path for the system firewall:
```powershell
go build -o build/dev/piik-app.exe ./cmd/piik-app
./build/dev/piik-app.exe
```
These commands build the App only. Choose **Local room** or a configured Site
for Browser capture. Native capture and **Public invite** need their helpers;
use `--capture-process` / `--tunnel-process` to select built helpers, or follow
[packaging](#packaging) for a complete bundle.
The repository-level entry used locally and by CI is:
```sh
@@ -163,8 +188,8 @@ The Darwin App and peer gate build only on macOS with cgo enabled and an
installed SDK; other hosts report that skipped platform explicitly. The pinned
media dependency's Darwin CPU statistics use Mach APIs through cgo, so a Windows
or Linux core check does not establish Darwin build acceptance.
Each target compiles its isolated capture process and validates its bounded
capability response. macOS additionally encodes one in-memory hardware H.264
The check compiles the current platform's isolated capture process and validates
its bounded capability response. macOS additionally encodes one in-memory hardware H.264
IDR; Linux probes the Portal/PipeWire/GStreamer adapter. Real capture, GPU
attribution, Browser decode, and public-network paths remain explicit physical
gates rather than environment-dependent unit tests.
@@ -198,9 +223,7 @@ verifies the pinned public-link helper, and assembles and checks the App:
```sh
node scripts/package-app-release.mjs /outside/repository/app-release
node scripts/package-client-candidate.mjs \
/outside/repository/app-release windows-amd64 \
/outside/repository/client-candidate
node scripts/package-client-candidate.mjs /outside/repository/app-release windows-amd64 /outside/repository/client-candidate
```
Supported targets are `windows-amd64`, `linux-amd64`, and `darwin-arm64`.
@@ -223,8 +246,9 @@ runtime/tunnel/cloudflared[.exe] # packages that support --link
```
The executable embeds the Browser assets of the consumed application release and
carries that same full Git revision as the package `REVISION`. No compatibility
reader accepts a mismatched private build.
carries that same full Git revision as the package `REVISION`. App/Site
interoperability follows the
[public compatibility contract](../../docs/reference/versioning.md#public-compatibility-promise).
The Windows App embeds the shared Piik mark through the
`cmd/piik-app/piik_windows_amd64.syso` resource; the platform
@@ -236,8 +260,8 @@ with an ICNS resource; the raw Go executable remains available beside it.
For a native Host smoke run, launch the App and select a window in the Host
page. The App never guesses among multiple targets. The page still creates
the room and sends the current SDP/ICE through the selected authority. A
configured Site supplies its normal Internet routing and SFU fallback. Without
a Site, the **Public invite** choice exposes the Local control surface while
configured Site supplies its normal Internet routing and any enabled SFU fallback.
Without a Site, the **Public invite** choice exposes the Local control surface while
media remains P2P-only.
## Gates
+276
View File
@@ -0,0 +1,276 @@
# Piik App
[English](./README.md) · 简体中文 · [上手指南](../../docs/guide/getting-started.zh-CN.md)
Piik App 通过系统浏览器中的 Piik 网页界面,提供本地房间与原生屏幕采集。
## 运行程序包
完整解压对应平台的程序包,保留 App 程序旁的 `runtime` 目录。
Windows 运行 `piik-app.exe`,Linux 运行 `./piik-app`,macOS 打开 `Piik App.app`。
启动页会在系统浏览器中打开。Linux 原生采集所需的系统依赖见
[Linux 采集指南](../../native/capture/linux/README.md)。
网页没有自动打开时,可手动打开终端中显示的地址,或在终端按 **O** 重试。
使用网页期间,请保持 App 运行。
目前主要测试 Windows 客户端和浏览器分享。macOS 与 Linux 客户端尚未经过实机测试,
欢迎试用并[反馈结果](https://github.com/TNTcraftHIM/Piik/issues)。macOS 原生采集需要
Apple 芯片及 macOS 13 或更新版本。构建程序包与公开发布 Release 是两个独立步骤,
详见[部署与发布](../../docs/deployment.md)。
## 运行模式
- 不传模式参数时,App 在系统浏览器中打开启动页。选择本地房间、临时公网 HTTPS 邀请
或已保存的站点后,进入通常的房主页面。
- 启动页会记住上次点击 **进入 Piik** 确认的模式,以及保存的站点地址。
没有已保存的模式时,有站点地址就默认选中站点,否则选中公网邀请;确认后才启动。
保存项的规则见 [App 配置](../../docs/reference/configuration.md#piik-app-configuration)。
App RPC 在选择前就已启动,同时接受已保存站点和本地房主来源的访问。
通过 App 打开的站点会在该浏览器来源下记住启用 App 的选择,之后手动打开的房主页面
可以继续使用正在运行的 App。
- `--site`、`--local` 和 `--link` 用于在 CI 或开发时直接选择模式。
- 默认启动页打开后会检查官方发布,优先使用 GitHub,GitHub 不可用时使用 Gitee。
有对应平台的 ZIP 时直接打开下载地址,否则打开发布页。App 不会自行安装或替换程序。
本地模式的房间只保存在内存中,使用 P2P 中继,不启动 SFU 监听器,也不使用公网发现服务。
普通本地模式适用于设备间可互相访问的局域网。**公网邀请** 模式运行程序包内的
Cloudflare Tunnel 辅助进程,仅暴露已有的 HTTP/WebSocket 控制服务;同时使用一个普通公网
STUN 地址和两个用于有限范围 NAT 探测的公网地址。浏览器与原生连接使用相同的预测规则;
原生 STUN、ICE 检查和媒体共用一个 Pion UDP mux。媒体不会经过 HTTP 隧道,
难以连通的媒体路径也没有 SFU 或 TURN 兜底。
本地邀请会自动选择唯一的活动地址或唯一的私有 IPv4 地址。仍有多个可能地址时,
请在启动页选择网卡和 IP。公网邀请和站点模式无需选择局域网地址。
通过 `--local` 跳过启动页时,可用 `--lan-address <address>` 指定地址来解决多地址歧义。
App 会在启动时再次检查所选地址;它只影响本地邀请链接,媒体路径仍由 ICE 独立选择。
系统浏览器始终是房主操作界面。通过 App 启动的房主页面同时提供浏览器标准采集弹窗,
以及平台上的具体采集目标列表,由用户明确选择。Windows 同样提供 VP8/Auto/H264 选择:
VP8 使用固定版本的 WebRTC/libvpx 编码器,H264 在 WebRTC 输出管线内使用硬件
Media Foundation。Auto 会测量目标画质配置下的编码工作量,再为本次分享选定一种编码。
Windows 使用 Graphics Capture 与 WASAPI;macOS 使用 ScreenCaptureKit、VideoToolbox
和 AudioToolbox;Linux 通过 ScreenCast Portal 选择来源,使用系统 PipeWire/GStreamer
硬件路径。
视频与音频共用房间路由和 PeerConnection。原生采集按房主当前的分辨率、帧率、
视频与音频码率及画质偏好启动;分享中修改设置只替换这些稳定连接后的采集与编码代次。
使用公网 STUN 的原生连接还会为其 Pion UDP socket 尝试一次有界的 PCP、UPnP 或 NAT-PMP
端口映射,纯局域网模式不会执行。路由器没有映射服务时,继续使用普通 ICE/STUN。
映射不会建立中继,也不会让媒体经过 App 控制连接。配置的站点可以从共享编码源直接接收
原生发布;本地与公网邀请模式仍只使用 P2P。普通网页房主保留浏览器采集路径,不主动探测 App。
原生 P2P 连接与内嵌 SFU 共用一套 Pion/LiveKit 媒体适配层,负责反馈、转发分配、
有界发送节奏和恢复。原生父节点复用合适的 H.264/VP8 输出,仅在直接子节点需要时生成
缺少的较低输出。兼容的子节点共用该输出,每条连接只接收为它选定的表示。
SFU 发布将其请求的输出前缀与一个聚合上行预算结合使用。较低输出的限制不会替换原始输入,
也不会替换其他同级连接使用的较高输出。路由控制器负责持续质量证据与路由替换。
具体行为见[媒体质量](../../docs/product/media-quality.md),验收边界见[当前状态](../../docs/status.md)。
App 配置保存可选的本地站点口令。留空表示本地站点开放进入;需要口令时,在启动页填写后再启动。
设置口令后,App 通过 URL fragment 将它交给自己的房主页面,页面调用已有的 SiteAccess
接口,并在继续前移除 fragment。观众邀请仍使用既有的房间级授权。
终端显示当前模式、入口链接与启动状态,语言跟随启动页和已启用 App 的页面。
按 `o` 重新打开浏览器,按 `q` 或 Ctrl+C 结束本地房间,停止本地服务与临时公网链接。
纯文本输出时使用 Ctrl+C。关闭站点标签页后,App 仍会继续运行。
App 在独立 Windows 控制台中启动时,启动或运行错误会保留在窗口中,按 Enter 后退出。
正常关闭、在已有终端中运行、重定向输出或自动化运行时,会直接退出。
临时公网分享时,打开 App 启动页,选择 **公网邀请**,在随后打开的浏览器中创建房间,
发送通常的邀请链接即可。随机的 `trycloudflare.com` 来源仅在本次 App 运行期间有效,
下次启动会创建新链接。Cloudflare Quick Tunnels 不保证持续可用;
需要持久的控制服务或 SFU 兜底时,请使用配置好的站点。
## 问题排查
### Chromium WebRTC 连接
**为什么屏幕采集成功了,媒体连接却失败?**
基于 Chromium 的浏览器可能通过浏览器设置、扩展或管理策略限制 WebRTC UDP。
禁止未经代理的 UDP,甚至会阻止本机浏览器与 App 之间的媒体连接;
采集成功或页面能打开,并不能证明这条独立连接可用。
检查浏览器的 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)。
### 诊断
在启动页开启 **诊断启动**,进入 Piik 并复现问题,再在交互终端按 `D` 导出本地 ZIP。
启动页出现前就失败时,使用 `--debug`。导出不会停止分享或上传文件。
浏览器诊断通过主题按钮后的 **诊断** 芯片图标开启:确认重新加载后,使用 **网页报告** 下载。
调查浏览器与 App 配合的问题时,请提供同一次复现的两份报告。
[诊断参考](../../docs/reference/configuration.md#diagnostics)说明日志位置、导出命令、保留规则与隐私边界。
## 开发
Node/npm/Go 版本见[从源码运行](../../docs/README.md#run-from-source)。
程序会内嵌浏览器资源,请先在仓库根目录构建:
```sh
npm ci
npm run build:client
```
Linux 或 macOS:
```sh
go run ./cmd/piik-app
```
Windows 使用固定的可执行文件路径,便于系统防火墙识别:
```powershell
go build -o build/dev/piik-app.exe ./cmd/piik-app
./build/dev/piik-app.exe
```
这些命令只构建 App。体验浏览器采集时,选择 **本地房间** 或配置好的站点。
原生采集和 **公网邀请** 需要各自的辅助程序,可用 `--capture-process` / `--tunnel-process`
指定已构建的程序,或按[打包](#打包)步骤生成完整程序包。
本地与 CI 共用的仓库级检查入口是:
```sh
npm run check:client
```
在 Windows 上,该入口将 App、采集及媒体测试程序写入已忽略的仓库 `build/client-check`
目录,并在下次运行时复用路径,保持系统防火墙识别的可执行文件身份稳定。
这些文件是本地构建输出,不会打包或提交。
检查包括 Go 格式、单元测试、vet,以及禁用 cgo 的 Windows/Linux 交叉构建。
Darwin App 和 peer gate 仅在安装 SDK 的 macOS 上启用 cgo 构建;其他系统会明确报告跳过该平台。
固定版本的媒体依赖在 Darwin 上通过 cgo 调用 Mach API 获取 CPU 统计,
因此 Windows 或 Linux 上的核心检查不能证明 Darwin 构建通过。
检查会编译当前平台的独立采集程序,并验证有界的能力响应。macOS 还会在内存中编码一个硬件 H.264
IDR;Linux 会探测 Portal/PipeWire/GStreamer 适配层。真实采集、GPU 归因、浏览器解码
和公网路径由明确的实机验证负责,不作为依赖环境的单元测试。
loopback 服务在 IPv4 回环地址的 `39721` 到 `39730` 中绑定第一个可用端口。
`/health` 用于发现当前进程;`/control` 最多接纳两个相互独立、使用严格 v9 协议的控制会话。
完成 `hello` 后,每个会话可以列出本地采集选项、管理一个按代次隔离的房主分享,
或接收一个原生观众来源及其数量受限的编码子连接。关闭会话只回收该会话的资源。
公开的 `instanceToken` 用于区分发现的进程,不是身份认证凭据。
共享 Go 服务负责房间权威状态,浏览器负责房间协议客户端,并通过控制会话协调原生媒体。
即使本机没有可用的原生采集编码器,观众接收与中继仍可使用。
采集辅助程序必须匹配 App 当前的探测与编码输出协议;候选打包脚本会先验证版本,再接受产物。
打包版本会在终端和诊断上下文中报告产品版本与源码 SHA。启动页用它们区分更新版本、
同版本的不同构建,以及向开发构建提供的正式发布;更新链接不会安装程序或打断分享。
这些含义由[版本规则](../../docs/reference/versioning.md)管理,自动发布由
[GitHub 运维](../../docs/operations/github.md)说明。
## 打包
从干净的源码版本开始,构建应用发布产物,再在目标平台的操作系统上运行候选打包脚本。
脚本会构建采集程序、验证固定版本的公网链接辅助程序,并组装和检查 App:
```sh
node scripts/package-app-release.mjs /outside/repository/app-release
node scripts/package-client-candidate.mjs /outside/repository/app-release windows-amd64 /outside/repository/client-candidate
```
支持的目标为 `windows-amd64`、`linux-amd64` 和 `darwin-arm64`,请替换命令中的目标名称。
Darwin 组装需要原生 macOS runner 与 SDK,并启用 cgo;Windows 与 Linux 组装禁用 cgo。
结果是 ZIP 程序包和 SHA-256 文件。手动组装辅助程序、显式 CI 打包、Release 发布与更新,
见[部署与发布](../../docs/deployment.md);创建候选包不会将它发布。
解压后的目录包含:
```text
piik-app[.exe]
REVISION
LICENSE
THIRD-PARTY-NOTICES.txt
runtime/native/piik-capture[.exe] # 支持原生媒体的程序包
runtime/tunnel/cloudflared[.exe] # 支持 --link 的程序包
```
App 内嵌所用应用发布产物的浏览器资源,程序内的完整 Git 版本与包内 `REVISION` 一致。
App 与站点之间的互操作遵循[公开兼容规则](../../docs/reference/versioning.md#public-compatibility-promise)。
Windows App 通过 `cmd/piik-app/piik_windows_amd64.syso` 资源内嵌共享的 Piik 标识;
平台后缀使该 Windows 资源不参与 Linux 和 macOS 构建。
Linux 程序包包含标准的 `share/applications` 桌面入口与 hicolor 图标。
macOS 程序包包含一个带 ICNS 资源的轻量 `Piik App.app` 启动器,原始 Go 程序仍保留在它旁边。
执行原生房主冒烟检查时,启动 App 并在房主页面选择一个窗口;有多个目标时,App 不会代替用户猜选。
页面仍通过选定的房间权威服务创建房间、发送当前 SDP/ICE。配置好的站点提供通常的公网路由,
以及已启用的 SFU 兜底。没有站点时,**公网邀请** 会暴露本地控制服务,媒体仍只使用 P2P。
## 验证入口
Local gate 会启动已打包进程,在 Chromium 中加载真实构建的页面,验证启动访问与局域网
邀请构造,并检查进程和浏览器配置的完整清理。loopback gate 分别验证授予和未授予
Chromium Local Network Access 权限时的站点访问。以下环境变量语法用于 POSIX shell;
Windows 请使用等效的环境变量设置方式。
```sh
PIIK_CLIENT_LOCAL_GATE=true \
CHROME_PATH=/path/to/chrome \
PIIK_CLIENT_EXE=/path/to/piik-app \
PIIK_CLIENT_GATE_LAN_ADDRESS=192.168.1.10 \
npm run gate:client-local
PIIK_CLIENT_LOOPBACK_GATE=true \
CHROME_PATH=/path/to/chrome \
PIIK_CLIENT_EXE=/path/to/piik-app \
npm run probe:client-loopback
PIIK_CLIENT_MEDIA_GATE=true \
CHROME_PATH=/path/to/chrome \
PIIK_CLIENT_GATE_STUN_URLS=stun:<stun-host>:3478 \
npm run gate:client-media
PIIK_CLIENT_NATIVE_HOST_GATE=true \
CHROME_PATH=/path/to/chrome \
PIIK_GO=/path/to/go \
npm run gate:client-native-host
PIIK_CLIENT_NATIVE_HOST_GATE=true \
PIIK_CLIENT_CROSS_NAT_GATE=true \
CHROME_PATH=/path/to/chrome \
PIIK_GO=/path/to/go \
PIIK_REMOTE_HOST=<public-test-host> \
PIIK_REMOTE_USER=<ssh-user> \
PIIK_REMOTE_SSH_KEY=/path/to/key \
PIIK_CLIENT_GATE_STUN_URLS=stun:<stun-host>:3478 \
npm run gate:client-native-host
PIIK_CLIENT_NATIVE_HOST_GATE=true \
PIIK_CLIENT_LINK_MEDIA_GATE=true \
CHROME_PATH=/path/to/chrome \
PIIK_GO=/path/to/go \
PIIK_CLOUDFLARED=/path/to/cloudflared \
PIIK_REMOTE_HOST=<public-test-host> \
PIIK_REMOTE_USER=<ssh-user> \
PIIK_REMOTE_SSH_KEY=/path/to/key \
npm run gate:client-native-host
PIIK_CLIENT_LINK_GATE=true \
PIIK_GO=/path/to/go \
PIIK_CLOUDFLARED=/path/to/cloudflared \
PIIK_REMOTE_HOST=<public-test-host> \
PIIK_REMOTE_USER=<ssh-user> \
PIIK_REMOTE_SSH_KEY=/path/to/key \
npm run gate:client-link
```
loopback 健康响应分别报告视频、进程音频、系统音频和硬件 H.264 是否可用。
Windows media gate 验证一个硬件 H.264 采集代次、共享 Pion 来源、浏览器解码、PLI 恢复和
STUN 候选收集。native Host gate 验证房间创建及当前路由上的原生视频传输;
能力探测与目标系统支持时,也包含原生音频。
跨 NAT 变体仅将临时反向 SSH 路径用于信令,并要求选中的媒体候选对包含 `srflx` 或 `prflx`;
媒体不会经过 SSH。公网邀请媒体变体通过 App 临时公网来源传送相同信令,
并要求将媒体直接传给独立的 Linux 对端。原生 P2P 质量证据与内嵌 SFU 传输有各自的验证入口。
macOS 和 Linux 采集仍需实机桌面与媒体验证,CI 编译及程序包冒烟检查不能替代这些验证。
+4 -4
View File
@@ -6,14 +6,14 @@
| Task | Guide |
| --- | --- |
| Try the online demo | [Online demo](https://demo.piik.tv) · [Demo steps](./guide/getting-started.md#try-the-online-demo) |
| Use Piik online | [Open the online site](https://demo.piik.tv) · [Online sharing guide](./guide/getting-started.md#use-piik-online) |
| Watch a friend or share my first screen | [Getting started](./guide/getting-started.md) |
| Choose an App mode or fix sound and connection trouble | [First-use help](./guide/getting-started.md#choose-an-app-mode) |
| Run Piik App, check platform requirements, or build a package | [App guide](../cmd/piik-app/README.md) |
| Run Piik App, check platform requirements, or build a package | [App guide](../cmd/piik-app/README.md) · [中文 App 指南](../cmd/piik-app/README.zh-CN.md) |
| Try or change the source code | [Run from source](#run-from-source) |
| Find a directory or the module responsible for a behavior | [Repository layout and module map](./reference/engineering.md#repository-layout) |
| Host a site for my group | [Self-hosting](./operations/self-hosting.md) · [中文部署](./operations/self-hosting.zh-CN.md) |
| Keep a server running with systemd or Docker | [Service management](./operations/service-management.md) |
| Keep a server running with systemd or Docker | [Service management](./operations/service-management.md) · [中文服务管理](./operations/service-management.zh-CN.md) |
| Set passwords, ports, or room storage | [Configuration](./reference/configuration.md) |
| Update my server | [Server updates](./operations/self-hosting.md#keep-it-running-and-update) |
| Build, publish or recover a maintained release | [Maintainer release tooling](./deployment.md) |
@@ -21,7 +21,7 @@
| Translate Piik or improve wording | [Translation guide](./guide/translating.md) · [中文翻译教程](./guide/translating.zh-CN.md) |
| Check release and platform readiness | [Current status](./status.md) |
The introduction, getting-started, self-hosting and translation guides are available in English and Chinese.
The introduction, getting-started, App, self-hosting, service-management and translation guides are available in English and Chinese.
Technical references keep one shared version.
## Run from source
@@ -63,7 +63,10 @@ in [ADR-0011](./0011-browser-assisted-native-fanout.md) remains independently ow
stale completion, and release the old group only after the owning operation
commits. Failure preserves the prior applied state. Pause synchronously gates
borrowed payload, including pending output; stop cancels pending preparation
and releases resources. A quiet source is not evidence of failure.
and releases resources. Local transport startup is bounded so a stalled
producer yields to ordinary encoding before the outer route readiness
deadline. Both local connections must connect; once connected, a quiet or
paused source is not evidence of failure.
7. Switching requires an accepted recovery frame and compatible key/delta,
dependency, codec and packetization metadata. Preserve ordering and discard
obsolete data only at a valid recovery boundary. Carrier timestamps and
+8 -2
View File
@@ -58,6 +58,12 @@ over a reused comic's default; text and pure-visual tooltips retain that context
PeerConnection errors remain diagnostic observations, not persistent red
warnings after media recovers. The control menu does not repeat the television
status or aggregate the first weak outbound edge into a global quality warning.
The operation supplies its outcome tone and matching scene together. Neither
the presence of an illustration nor a default checkmark proves success;
partial application retains a limitation instead of claiming completion.
- Topology: unready edges use the blue pending treatment. Readiness alone cannot
distinguish initial connection from recovery, so this view does not invent a
warning. Confirmed interruption remains owned by the status projection.
- Title: keeps the primary activity, with a compact warning for an applicable
problem. Losing focus alone never means waiting for media.
- Recovery: every projection is recomputed from current facts. A warning requires
@@ -95,8 +101,8 @@ Participant status reads the current evidence directly; it adds no stored
status, timer, threshold or score. Detailed sender/receiver metrics
remain available in connection details and Debug with their original scope.
The preview labels supplied example quality facts for the displayed stream.
Examples do not authorize guessing unavailable production facts.
Previews render the production projection and metric components with explicit
sample inputs. They do not add quality facts absent from the real workflow.
## References
+3 -2
View File
@@ -89,8 +89,9 @@ The short comics illustrate choosing, inviting and joining, following the
shared motion grammar and whole-control replay rule. The optional film starts
only after an explicit action and has native buttons and a seek slider. Open it
within the homepage, loading its single player on demand; closing it unloads
playback. The standalone page serves recording and direct links. Fullscreen
watching keeps touch controls available; hiding them is an explicit recording action.
playback. Keep one homepage playback entry; the player document also supports
local recording. Fullscreen watching keeps touch controls available; hiding
them is an explicit recording action.
Both READMEs share the friendly opening and room illustration. Their feature,
limitation and setup sections use the factual register in the copy guide.
+125 -5
View File
@@ -55,15 +55,36 @@ second activation, outside tap, Escape or focus leaving. Action controls keep
their direct click action and offer touch guidance on long-press. `Tooltip`
owns both interactions; pages do not add their own open state or timers.
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
need another popup. Keep participant details on their existing click action and
state explanations on the dedicated status indicator.
When a nickname is clipped, the shared tooltip reveals its complete literal
text in every language mode, without a comic or repeated role/status labels.
Show this only after detecting actual truncation; resize and name changes must
recheck it. Preserve hover, keyboard and touch-hold access, and the participant's
existing click action. A complete or wrapping name needs no extra focus stop or
popup. If a status tooltip already wraps the row, add the clipped name there
rather than nesting another tooltip.
Tooltips, state overlays and entry/error pages reuse the same illustrative scene
in every mode.
Chinese and English add a concise caption beneath it; pure-visual mode keeps the
scene and its playful pictograms as an explicit alternative. Names, addresses and other literal
values without a corresponding scene remain text. Illustration and caption
scene and its playful pictograms as an explicit alternative. Literal names and
addresses keep their exact text. An invitation explanation retains its link
scene; simply revealing a clipped name needs only the name.
Do not substitute an unrelated action just to fill a tooltip. Illustration and caption
explain the same action or current fact, with one tone and motion owner. Mount
scenes only while their tooltip or overlay is shown; captions remain readable
throughout.
Metric icons and hints share the `metric-presentation.ts` definition in details
and overview headings. Frames, packets, jitter and audio repair have distinct
symbols; related time measurements keep a clock with a scene identifying the
measured stage. Illustrations explain a metric, never invent its current value,
threshold or quality verdict.
Shared buttons and chips use pill shapes, a quiet lower edge and a short lift
on hover or keyboard focus, then compress on press. Inputs and option tiles
share softer corners. Keep a distinct focus ring and a persistent selected
@@ -118,6 +139,80 @@ Use differences in tone, spacing and name tags to keep people readable; retain
only the few interior seams that clarify cushions. Apply this convention to
the website and README cast as well as the product UI.
## Symbol Reference
This reference applies to controls, status strips, comics, overlays, document
titles, placeholders and public product demonstrations. A symbol keeps its
basic meaning across those surfaces. Its nearby label identifies the affected
object; it must not silently acquire a different measurement or permission.
This reference describes functional meaning in context, not exclusive ownership
of every shape. Decorative lettering, welcome ornaments and shared screen
content do not become controls or measurements by using a familiar symbol.
[Glyph](../../src/client/ui/icons.tsx) owns small-icon geometry and the accepted
`GlyphName` union. Use it for controls and embedded comic symbols; reuse `MiniTv`,
`Pawn`, `InviteLink` and the other shared scene objects for illustrations. Select
existing meanings before adding an icon. A new meaning needs a row here and a
rendered example in the existing catalogue. A typo or missing icon must fail
type checking rather than silently render a warning triangle.
| Meaning / 含义 | Symbols | Use and boundary |
| --- | --- | --- |
| Participants and room / 人与房间 | `users`, `couch`, `door` | People, shared room, room entry. Use the shared pawn for a person and `HostMark` for Host authority. |
| Shared screen / 共享画面 | `tv`, `MiniTv`, 📺 | Media or its sharing source. The TV beside the Host name identifies the source; the crown on a person identifies the role. |
| Start and choose a source / 分享与选源 | `cast`, `share`, `switchSource` | Start sharing, capture a screen, replace the current capture source respectively. A captured window keeps window chrome or a display stand. |
| Capture source / 采集来源 | `window`, `display` | An application window or a whole display; these nouns remain distinct from the source-switch action. |
| Invitation URL / 邀请链接 | `link`, `InviteLink`, `linkOff` | A URL and its disabled/revoked form. Keep the chain when copying or rejecting it. Copying is local; a send-to-chat scene is a separate action. |
| Credential / 凭证 | `key`, password dots | Site passphrase or room password, identified by the field label. Room-code admission with a password shows both fields. |
| Restriction / 受限 | `lock` | Restricted entry, concealed password or a setting fixed by availability; show the affected object. A lock never promises anonymity or secure media. |
| Site and public entry / 站点与公开入口 | `globe` | Browser/Site entry or code-based public entry. It does not claim internet reachability. |
| Connections / 连接 | `network`, `branch`, `signal`, `wifiOff`, `server` | Topology, branching, signaling, disconnected signaling and the named server. A server on a media path means forwarding; a Site backend or control-link server alone does not imply SFU. An invitation chain does not represent signaling. |
| Playback / 播放 | `play`, `pause`, `stop` | Play/resume, pause, end. The control label states whether it affects local viewing or the Host's share. Stop is distinct from a paused or idle room. |
| Audio / 声音 | `speaker`, `speakerOff` | Audio and mute/no audio, qualified by the source or local playback label. A source track's presence does not establish delivered sound. |
| Size and viewing mode / 尺寸与观看模式 | `expand`, `contract`, `pip`, `pipExit`, `theater`, `theaterExit` | Expand/restore, enter/leave picture in picture, enter/leave theatre mode. The same size symbol can accompany a resolution measurement. |
| Local actions / 本地操作 | `copy`, `save`, `pencil`, `eye`, `eyeOff` | Copy, save, edit, reveal and conceal the named object. An action icon does not prove completion. |
| Repeat and disclosure / 重试与展开 | `refresh`, `chevron`, `arrowRight` | Repeat/refresh, expand/collapse, proceed/enter. Direction follows the control's action; no arrow alone establishes delivery. |
| Transfer and upgrade / 传输与更新 | `arrowUp`, `arrowDown` | Outbound/inbound or upload/download, qualified by the adjacent label. An explicit update label may use the upward arrow for upgrade; neither arrow is a connection-quality rating. |
| Diagnostics / 诊断 | `cpu` | Technical diagnostics in the labelled diagnostic control; in metrics the same processor identifies encoding work. The surrounding control or measured field states the scope. |
| Settings / 设置 | `sliders`, `mountain`, `balance`, `frames` | Settings, preserve image detail, balanced preference, preserve frame rate. The frame-rate preference uses the same frames as the measured value. |
| Progress and result / 进度与结果 | `loader`, `alert`, `check`, `x` | In progress, a problem, confirmation/positive result, cancellation/negative result. The status owner supplies severity; the shape alone cannot set it. |
| Theme / 主题 | `sun`, `moon` | Light/dark appearance in theme controls. A moon in an idle-room scene means rest, with the idle scene providing that context. |
| Welcome decoration / 开场装饰 | `gamepad`, `popcorn`, `clapperboard`, `trophy`, `gift`, `flag`, `heart`, `bulb`, `zap` | Games, snacks, film, achievement, gift, next stop, affection, discovery and energy. These may decorate the welcome line, never a measured speed, failure or connection verdict. |
Metrics use [METRIC_PRESENTATION](../../src/client/components/living/metric-presentation.ts)
in both summaries and details. Labels, units and stage-specific comics distinguish
related values; views do not choose their own symbols.
Topology hover and keyboard focus use neutral outlines; selection adds a heavier
outline and text weight. Connection colours remain owned by route/readiness
facts, so interacting with a node cannot look like a connection-state change.
| Measurement / 指标 | Symbol | Boundary |
| --- | --- | --- |
| Frame rate / 帧率 | `frames` | Video frames per second, including capture/input frame rate. |
| Packet loss / 丢包 | `packetLoss` | Missing network packets, including audio packets. |
| Dropped frames / 丢帧 | `frameDrop` | Discarded video frames in the stated observation interval; never substitute packet loss or a cumulative total under the same interval label. |
| Jitter / 抖动 | `jitter` | Variation in arrival timing, including audio jitter. |
| Audio concealment / 音频补偿 | `audioRepair` | Missing audio concealed by the decoder. |
| Rate / 速率 | `gauge`, `speaker` for audio rate | Bitrate or outgoing rate; the unit remains visible. |
| Duration / 耗时 | `clock` | RTT, encode/decode time, freeze duration, playout or buffer time. The label and comic identify the measured stage. |
| Freeze count / 卡顿次数 | `pause` | Count of interrupted video progress, distinguished by its metric label from intentional pause. |
| Encoder and codec / 编码器与编码格式 | `cpu`, `puzzle` | Processing implementation and encoded format respectively. |
Text-only surfaces retain the same meanings:
| Surface | Standard and owner |
| --- | --- |
| Activity in document titles | [Title catalogs](../../src/client/locales/visual.ts): 📺 sharing/viewing, 🛋️ ready room, 💤 idle, ⏳ waiting/starting, ⏸️ paused, ▶️ waiting for playback, ⏹️ ended, ❗ unavailable. Secondary decorations cannot replace this leading meaning or imply new progress. |
| A problem beside an activity | [Media status](../../src/client/ui/media-status.ts) supplies ⚠️ from actual facts; [document-title](../../src/client/ui/document-title.ts) only composes and rotates titles. |
| Default names | [display-name](../../src/client/lib/display-name.ts) owns 📺 for the sharing source and 👤 for a Viewer in visual mode. Preserve user-entered emoji names as data; never infer role from their characters. [viewer-presence](../../src/client/lib/viewer-presence.ts) owns duplicate-name suffixes. |
| Visual-mode shortcut | ✦ in the language control and its documentation; not a success or quality mark. |
| Input placeholders | Four slots represent the four-digit room code. Password dots mask a field; their illustrated count imposes no password length. Numeric samples reuse the actual field's `--mono` typeface and weight. Real inputs retain localized labels. |
| Unknown values | `—` means unavailable/unknown, never zero or a healthy result. Keep exact values and units whenever known. |
| Capability values | `✓` / `✗` answer the adjacent boolean capability, not whether an entire route or share succeeded. |
| Shortened text | `…` means truncation. Preserve the full nickname/URL for reading and copying; never turn an exact value into a decorative code. |
| Welcome cipher | [WelcomeLine](../../src/client/components/living/WelcomeLine.tsx) owns the paired symbols and pixel words, tied to the same selected line. They are decoration, not hidden connection state. |
## Semantic Colour And Shape
Use the existing theme tokens in `src/client/styles.css`. The shared
@@ -139,6 +234,12 @@ prospective action is not proof of success: opening details, switching theme,
adjusting volume or offering P2P stays neutral. Actual copy feedback can change
that same hint to green or red, using the existing operation result.
Review meaning at the call site. A current admission badge shows the credential
accepted by [room access](../product/rooms-access.md), not the act of switching
privacy. Password input is not proof of admission; copying writes to the local
clipboard and does not send to a friend. An audio track's presence does not
establish a locked control or delivery to another device.
Two-panel comics always read before/context on the left and result/current
condition on the right. Only the result carries the semantic accent; a single
panel carries the current condition. Tooltip outline and caret use that same
@@ -158,7 +259,7 @@ possibility. A rejected PiP request is failure; lack of PiP support is a limitat
| --- | --- |
| Action demonstration (`demo`) | Play one meaningful transition when shown, then hold its result |
| Work actually continuing (`progress`) | Repeat a gentle connection/recovery beat while that state exists |
| Settled success, failure, limitation, pause or idle (`still`) | Show the informative final pose immediately; no repeated stamping, shaking, blinking or celebration |
| Settled success, failure, limitation, pause or idle (`still`) | Keep the result readable while a related subject moves briefly, then holds; do not repeat the operation, retry or celebrate |
Demonstrations and progress share the existing 3.2-second comic beat. The main
action belongs in its first half, followed by a readable hold; small internal
@@ -172,6 +273,21 @@ result or shows a misleading starting pose. Static styling is scoped to its SVG;
one still tooltip must not freeze another progress scene. New keyframes must end
in the documented result rather than reset for an obsolete loop.
Every comic surface, including overlays and error pages, needs visible motion
in its subject: a paused scene's steam rises, a loose cable sways, or packets
advance toward a bottleneck. Whole-scene entrance, blinking or sparkle alone
does not satisfy this. Settled result marks stay readable during that movement.
Changing the comic's meaning starts its animation afresh; changing a caption,
language or numeric value alone does not. The preview's reduced-motion setting
uses the same explicit final poses as the system setting.
Check scenes in isolation, including settled overrides and quick re-entry while
a tooltip is exiting, so another mounted scene cannot hide missing CSS or a
finished animation. Reduced-motion rules must outrank settled-motion overrides.
Also check the actual controls together, in every relevant property combination.
Judge motion at its displayed size: a changing transform on a tiny figure can
still look frozen. The default scene catalogue does not cover a Pill's settled
override or missing preview states by itself.
The tooltip panel itself uses the same restrained entrance/exit for every tone.
Replay belongs to opening/hovering/focusing the whole control, not just its SVG.
Ordinary controls keep their icons visible during hover and keyboard focus;
@@ -201,8 +317,8 @@ for reduced motion. Product-state comics still follow the table above.
Entry screens may carry one quiet, original welcome line in Chinese or English
text mode. Pure-visual mode pairs familiar pictograms with static pixel
pseudo-lettering tied to the same line. This decoration is hidden from assistive
technology. The selected line stays stable during that visit, including mode
pseudo-lettering tied to the same line. Hide that visual cipher from assistive
technology; the ordinary text sentence remains readable. The selected line stays stable during that visit, including mode
changes, and never replaces an action, error, loading message or connection
progress. Keep it on one line, apart from the actions; primary controls take
precedence when space is tight.
@@ -233,3 +349,7 @@ distinguishes functional feedback from occasional expressive movement.
[Web Animations start time](https://developer.mozilla.org/en-US/docs/Web/API/Animation/startTime)
provides the native timeline alignment used by participant gestures.
[Carbon tooltip guidance](https://carbondesignsystem.com/components/tooltip/usage/)
supports concise, contextual help where it adds information; required instructions
stay visible beside the action.
+9 -11
View File
@@ -3,17 +3,15 @@
English · [简体中文](./getting-started.zh-CN.md) · [Back to Piik](../../README.md)
Piik shares one person's screen with up to 20 invited viewers. This guide covers
watching, trying the online demo, using Piik App and sharing through an existing site.
watching, using Piik online, using Piik App and sharing through an existing site.
[Join a room](#join-a-friends-room) · [Online demo](#try-the-online-demo) ·
[Join a room](#join-a-friends-room) · [Use online](#use-piik-online) ·
[Piik App](#share-with-piik-app) · [Existing site](#share-from-your-browser) ·
[Troubleshooting](#when-something-gets-in-the-way)
Viewers need a browser and an invitation. To try sharing, use the online demo
or **Public invite** in Piik App.
If Piik shows icons only, choose **EN** in the header to show the labels
used below. **中** switches to Chinese; **✦** returns to the illustrations.
Viewers need a browser and an invitation. To start sharing, download Piik App
and choose **Public invite**. To share without installing, open
[Piik online](https://demo.piik.tv) and create a room in your browser.
## Join a friend's room
@@ -46,7 +44,7 @@ you cannot join by code.
If an old link no longer works, ask the host for the current invitation.
App Local rooms and temporary public invitations end when that App run stops.
## Try the online demo
## Use Piik online
1. Open [demo.piik.tv](https://demo.piik.tv) in a desktop browser that supports
screen sharing.
@@ -56,7 +54,7 @@ App Local rooms and temporary public invitations end when that App run stops.
second device or send it to a friend.
4. Select **Stop sharing** when finished.
The project hosts this site for you. It uses direct media connections (P2P);
The online version is provided by the project. Media travels only between participants (P2P);
restrictive networks may load the page but block video.
## Share with Piik App
@@ -89,7 +87,7 @@ See the [App guide](../../cmd/piik-app/README.md) for package and runtime detail
| Mode | When to choose it | What to keep in mind |
| --- | --- | --- |
| **Local room** | Friends are on the same local network. | If several **Local invitation addresses** appear, choose the interface and IP on your friends' network. Your computer must be reachable from theirs. A local site passphrase is optional. |
| **Local room** | Friends are on the same local network. | If several entries appear under **Local invitation address**, choose the interface and IP on your friends' network. Your computer must be reachable from theirs. A local site passphrase is optional. |
| **Public invite** | Friends are elsewhere and you want a temporary invitation. | Needs Internet access. The public address lasts for this App run. |
| **Connect to Site** | Your group already has a Piik site. | Enter its address; the App adds capture capabilities to the site's sharing page. |
@@ -102,7 +100,7 @@ The [App guide](../../cmd/piik-app/README.md#modes) explains the connection setu
## Share from your browser
You need an existing HTTPS site running Piik Server, provided by a friend or
administrator. If you do not have one, start with the [demo](#try-the-online-demo)
administrator. You can also use [Piik online](#use-piik-online)
or [Piik App](#share-with-piik-app). Hosting a site yourself is an advanced option:
follow the [self-hosting guide](../operations/self-hosting.md) for server, HTTPS
and network setup.
+12 -14
View File
@@ -3,16 +3,14 @@
[English](./getting-started.md) · 简体中文 · [回到 Piik](../../README.zh-CN.md)
Piik 支持一位房主分享屏幕,最多 20 位受邀观众通过浏览器观看。
本指南介绍加入房间、体验在线 Demo、使用客户端,以及通过已有站点分享的方法。
本指南介绍加入房间、使用在线版、使用客户端,以及通过已有站点分享的方法。
[加入房间](#加入朋友的房间) · [在线 Demo](#体验在线-demo) ·
[加入房间](#加入朋友的房间) · [在线版](#使用在线版) ·
[Piik App](#用-piik-app-分享) · [已有站点](#用浏览器分享) · [问题排查](#遇到问题了)
观众只需要浏览器和邀请链接。
初次体验分享,可以使用在线 Demo 或客户端的 **公网邀请**。
如果 Piik 只显示图示,点击顶部的 **中** 即可显示下文提到的按钮文字。
**EN** 切换到英文,**✦** 返回图示模式。
分享画面推荐下载 Piik App,选择 **公网邀请**。
想免安装,可以打开[在线版](https://demo.piik.tv),直接在浏览器里创建房间并分享。
## 加入朋友的房间
@@ -43,14 +41,14 @@ Piik 支持一位房主分享屏幕,最多 20 位受邀观众通过浏览器
旧链接失效了,就请房主重新发一份当前邀请。
App 的本地房间和临时公网邀请会随本次 App 退出而结束。
## 体验在线 Demo
## 使用在线版
1. 在支持屏幕共享的电脑浏览器中打开 [demo.piik.tv](https://demo.piik.tv)。
2. 点击 **开始分享**,选择屏幕、窗口或标签页;如需声音且浏览器提供该选项,启用音频。
3. 确认预览后,点击 **复制邀请链接**,用另一台设备打开,或发送给朋友。
4. 体验结束后,点击 **停止分享**。
4. 分享结束后,点击 **停止分享**。
演示站由项目提供,画面通过设备间直连(P2P)传输。
在线版由项目提供,画面仅通过设备间直连(P2P)传输。
在受限网络下,可能出现页面能打开、画面无法连接的情况。
## 用 Piik App 分享
@@ -75,7 +73,7 @@ App 的本地房间和临时公网邀请会随本次 App 退出而结束。
目前主要测试 Windows 客户端和浏览器分享。macOS 和 Linux 客户端尚未经过实机测试,
欢迎有设备的朋友试用并[反馈问题](https://github.com/TNTcraftHIM/Piik/issues)。
Linux 原生采集还需要[系统组件](../../native/capture/linux/README.md)。
程序包与启动参数的细节见 [App 指南](../../cmd/piik-app/README.md)。
程序包与启动参数的细节见 [App 指南](../../cmd/piik-app/README.zh-CN.md)。
### 选择 App 模式
@@ -88,12 +86,12 @@ Linux 原生采集还需要[系统组件](../../native/capture/linux/README.md)
**网页能打开,不代表视频一定能连通。** 公网邀请为房间提供临时网页地址,
画面和声音仍靠参与者之间的连接传输。此模式没有媒体服务器兜底,
临时地址也不保证持续可用。所有模式都需要可用的 UDP 媒体路径。
具体连接方式见 [App 指南](../../cmd/piik-app/README.md#modes)。
具体连接方式见 [App 指南](../../cmd/piik-app/README.zh-CN.md#运行模式)。
## 用浏览器分享
需要先有一个运行 Piik Server 的 HTTPS 站点,可以使用朋友或管理员提供的地址。
还没有站点时,请先体验[在线 Demo](#体验在线-demo) 或使用[客户端](#用-piik-app-分享)。
也可以使用项目提供的[在线版](#使用在线版)或[客户端](#用-piik-app-分享)。
自建站点属于进阶操作,服务器、HTTPS 和网络端口的配置步骤见[部署指南](../operations/self-hosting.zh-CN.md)。
1. 在电脑上打开 Piik 站点。如果站点设置了口令,先输入口令。
@@ -112,9 +110,9 @@ Linux 原生采集还需要[系统组件](../../native/capture/linux/README.md)
| --- | --- |
| 有画面,没声音 | 先取消视频静音。房主应选择支持分享声音的来源;如果开始时关闭了声音,请停止后重新开始并打开声音。通过 App 采集窗口或屏幕时,切换来源会保留原来的声音设置。 |
| 没有屏幕选择弹窗 | 按系统提示允许浏览器或 App 录制屏幕。浏览器采集需要 HTTPS 或 `localhost`,请在电脑上尝试分享。 |
| App 启动失败 | 先查看页面和黑窗口中的具体原因。重新打开 App,在主题按钮后的芯片图标开启「诊断启动」再重试。启动失败时会自动导出报告,保存位置见黑窗口。 |
| App 启动失败 | 先查看页面和终端中的具体原因。重新打开 App,在主题按钮后的芯片图标开启「诊断启动」再重试。启动失败时会自动导出报告,保存位置见终端。 |
| 本地邀请打不开 | 确认两台设备在同一网络且可以互相访问。访客 Wi-Fi 或防火墙规则可能阻止本地连接。 |
| 网页能打开,画面连不上 | 点击播放控制栏的 **重新连接**;仍然失败时,检查 [WebRTC 连接设置](../../cmd/piik-app/README.md#chromium-webrtc-connections),或尝试有媒体服务器兜底的站点。 |
| 网页能打开,画面连不上 | 点击播放控制栏的 **重新连接**;仍然失败时,检查 [WebRTC 连接设置](../../cmd/piik-app/README.zh-CN.md#chromium-webrtc-连接),或尝试有媒体服务器兜底的站点。 |
| 休眠或挂起后分享中断 | 唤醒设备,回到分享标签页,必要时重新开始分享。浏览器或系统挂起可能中断采集和播放。 |
反馈问题时,请附上版本、系统与浏览器、预期结果和复现步骤。
+4 -4
View File
@@ -36,7 +36,7 @@ Piik 目前提供简体中文和英文。欢迎改进按钮措辞、校对教程
| 随机欢迎语、浏览器标签页的轮换标题 | 同一语言文件中的 `welcome.*` 及文件末尾的标题词库 |
| 官网 | [页面正文](../../site/index.html)和[交互文案](../../site/main.js),按 `en`、`zh-CN` 成对组织 |
| 宣传短片 | [短片源码说明](../../site/film/README.md),字幕、插画和播放控件有各自的中英文内容 |
| App 黑窗口 | [console.go](../../internal/app/console.go) 中的 `consoleCopy`,每项按英文、中文、纯视觉排列;第三项即使为空也需保留 |
| App 终端 | [console.go](../../internal/app/console.go) 中的 `consoleCopy`,每项按英文、中文、纯视觉排列;第三项即使为空也需保留 |
| README 与入门教程 | [README](../../README.md)、[使用指南](./getting-started.md)、[部署指南](../operations/self-hosting.md),均有对应的 `.zh-CN.md` 文件 |
App 与 Server 使用同一套网页操作界面。请修改源码,构建目录和发布包由工具生成。
@@ -64,7 +64,7 @@ App 与 Server 使用同一套网页操作界面。请修改源码,构建目
词库使用普通字符串,不解析 HTML 或 ICU 复数表达式。
如果目标语言需要复数变化或不同的句子组合,请给出具体例子,便于连同调用处一起调整。
保留代码的引号、转义和逗号。命令、配置项、URL、下载文件名,以及 Go 黑窗口文案中的 `%s`
保留代码的引号、转义和逗号。命令、配置项、URL、下载文件名,以及 Go 终端文案中的 `%s`
等格式标记保持原样。链接显示文字和图片说明可以翻译;Markdown 标题变化后,要修正对应的页内链接。
许可证原文及第三方声明保持完整。
@@ -94,8 +94,8 @@ App 与 Server 使用同一套网页操作界面。请修改源码,构建目
用户保存的选择优先。
App、Server 网页界面及官网对中文地区和文字变体的默认选择,遵循
[统一的系统语言规则](../product/presentation-lifecycle.md#visual-language)。
3. 检查完整界面与启动流程。新增的界面语言在 App 黑窗口中暂用英文;
翻译黑窗口时,还需同步启动服务和 loopback 对语言的校验,相关位置见
3. 检查完整界面与启动流程。新增的界面语言在 App 终端中暂用英文;
翻译终端时,还需同步启动服务和 loopback 对语言的校验,相关位置见
[App 模块地图](../reference/engineering.md#module-map)。
4. 在 PR 中写清覆盖范围。官网、短片和文档可以分别翻译,新增语言时需同步各自的语言切换入口。
从右向左书写的语言还需要验证文字方向与布局;仅有词库并不代表已支持 RTL 排版。
+5
View File
@@ -76,6 +76,11 @@ working directory. A blank `SITE_ACCESS_PASSWORD` allows
entry without a site passphrase; enter a passphrase to require one.
Room invitations and access settings still apply.
`MAX_VIEWERS_PER_ROOM` sets the room's Viewer limit, excluding the Host. Choose
`1..20` and restart to apply it. More Viewers can require more network and relay
resources. See [room capacity](../reference/configuration.md#room-capacity)
for defaults and the difference from App rooms.
### 2. Enable HTTPS
Use your existing HTTPS reverse proxy to forward to `127.0.0.1:8787` with
+6 -2
View File
@@ -40,7 +40,7 @@ docker compose up -d
接着完成下方的 [HTTPS 配置](#2-配置-https)和[端口放行](#3-放行端口并检查)。
默认使用 P2P;如需 SFU 兜底,按 `.env` 中的说明启用即可。
请保留 `piik-data` 数据卷,房间数据和可选诊断文件都保存在其中。
更新与备份见[容器维护说明](./service-management.md#container)。
更新与备份见[容器维护说明](./service-management.zh-CN.md#容器)。
## 对外提供服务
@@ -71,6 +71,10 @@ SITE_ACCESS_PASSWORD=
`SITE_ACCESS_PASSWORD` 留空时,进入站点无须口令;
如需设置口令,填写你想使用的口令即可。房间邀请与加入权限仍由房主管理。
`MAX_VIEWERS_PER_ROOM` 可设置每房观众上限,不含房主,支持 `1..20`,修改后重启生效。
人数越多,对网络和转发资源的需求也可能增加。默认值及 App 房间的区别见
[人数限制](../reference/configuration.md#room-capacity)。
### 2. 配置 HTTPS
如果已有 HTTPS 反向代理,将请求转发到 `127.0.0.1:8787`,并启用 WebSocket 支持。
@@ -102,7 +106,7 @@ TCP 8787 仅供本机反向代理访问。STUN 域名需要直接解析到服务
## 长期运行与更新
需要开机启动时,参阅 [systemd 与容器配置](./service-management.md)。
需要开机启动时,参阅 [systemd 与容器配置](./service-management.zh-CN.md)。
更新时保留 `.env` 和 `rooms.sqlite`:停止服务、备份房间数据、替换新版程序,再启动并检查健康状态和房间访问。
涉及数据格式变化的版本,请先阅读发布说明。
+4 -2
View File
@@ -1,5 +1,7 @@
# Service Management
English · [简体中文](./service-management.zh-CN.md) · [Documentation](../README.md)
For first installation, use the [short self-hosting guide](./self-hosting.md).
This reference covers a persistent Linux service and container maintenance.
@@ -93,8 +95,8 @@ The bare-metal `release-app.sh` wrapper does not manage containers.
- The official Trickle ICE sample obtains an ordinary UDP `srflx` candidate from
the configured STUN service and no relay candidate.
- A direct room presents media on two networks when Peer ICE succeeds.
- A constrained route creates one managed Host publication and only admitted
subscriptions over embedded SFU UDP; Peer descendants remain ordinary P2P.
- When SFU is enabled, a constrained route creates one managed Host publication
and only admitted subscriptions over embedded SFU UDP; Peer descendants remain ordinary P2P.
- Blocking all UDP reaches a clear bounded failure.
- Restarting Piik in stable mode retains room authority while Browser
sessions and media reconnect from fresh process state.
@@ -0,0 +1,91 @@
# 服务管理
[English](./service-management.md) · 简体中文 · [文档导航](../README.md)
首次安装请从[部署指南](./self-hosting.zh-CN.md)开始。本页介绍 Linux 常驻服务与容器维护。
## systemd
[服务模板](../../deploy/systemd/piik.service.example)使用 `piik` 用户运行,
读取 `/etc/piik/piik.env`,启动 `/opt/piik/current/piik-server`。
请创建服务账号,将解压后的发布产物放在该路径,或让 `current` 指向对应发布目录。
将配置复制到 `/etc/piik/piik.env`,并设置 `ROOM_DATABASE_PATH=/var/lib/piik/rooms.sqlite`;
服务单元会创建这个状态目录。环境文件只应允许运维人员读取。
将模板安装为 `/etc/systemd/system/piik.service`,然后执行:
```sh
sudo systemctl daemon-reload
sudo systemctl enable --now piik
sudo systemctl status piik
```
HTTPS 代理与 Piik 在同一主机时,让 HTTP 继续绑定回环地址。
STUN 和可选的 SFU 使用各自的公网 UDP 监听器。已有 nginx 时,可修改
[代理示例](../../deploy/nginx/piik.conf.example)中的域名与证书路径后使用。
## 容器
安装步骤见 [Docker Compose 配置](./self-hosting.zh-CN.md#使用-docker-compose)。
镜像为 `ghcr.io/tntcrafthim/piik`,提供 `latest` 和对应产品版本标签(`vMAJOR.MINOR.PATCH`),
支持 **linux/amd64**。在 `.env` 中设置 `PIIK_IMAGE` 可固定版本或镜像摘要。
更新需要手动执行;仅拉取镜像不会替换正在运行的容器。
[运行时 Dockerfile](../../deploy/container/Dockerfile)封装已验证的 Server 压缩包,
包括网页界面、许可证声明和 `REVISION`。固定版本的
[Distroless 非 root 基础镜像](https://github.com/GoogleContainerTools/distroless)
包含 CA 证书,没有 shell 或包管理器。镜像的构建、检查和发布由
[发布流程](../reference/versioning.md#container-distribution)管理。
使用既有的[生产配置](../reference/configuration.md)。容器内保留 `LISTEN_HOST=0.0.0.0`,
并继续使用主机已有的 HTTPS/WebSocket 代理。使用 Docker 端口映射且启用 SFU 时,
将 `SFU_PUBLIC_IP` 设为外部可达的服务器 IPv4 地址;容器私网候选无法从互联网访问。
公布的 STUN 域名必须解析到服务器公网地址,UDP 必须能直接到达容器。
[Compose 文件](../../deploy/container/compose.yaml)将 HTTP 绑定到主机回环地址,
公布标准 UDP 端口,并启用自动重启。公布端口不会开启对应服务,可选监听器由 `.env` 控制。
修改监听端口时,同时修改应用配置和端口映射。
请使用主机上的代理,或明确调整代理容器的网络配置;代理容器自己的 `127.0.0.1`
无法访问另一个容器内的 Piik。
镜像中的 `/home/nonroot` 属于 UID/GID `65532:65532`,权限为 `0700`。
新的命名数据卷继承该目录;已有数据卷或绑定挂载需要事先允许该身份写入。
替换容器时保留这个数据卷。设置 `ROOM_DATABASE_PATH=:memory:` 可让房间权威状态只保存在内存中。
诊断保持手动开启,使用 `/home/nonroot/logs`;[导出与保留](../reference/configuration.md#diagnostics)
由运维人员负责。不要将应用文件挂载为可写。
启用诊断后,`docker compose kill --signal=SIGUSR1 piik` 会请求本地导出,不会停止容器;
ZIP 保存在挂载的日志目录中。
从主机与公网代理检查 `/healthz`,再按[运行验证](#运行验证)检查已配置的 STUN/SFU UDP,
以及房间和媒体行为。镜像内没有基于 shell 的健康检查。
替换前保留旧镜像、环境配置与数据卷备份。在部署目录执行:
```sh
docker compose pull
docker compose stop
docker compose cp piik:/home/nonroot ./piik-data-backup
docker compose up -d
docker compose logs --tail=50 piik
```
每次使用新的备份目录。复制 SQLite 前先停止 Piik,确保数据库和日志文件一致。
单独保存 `.env`,并将配置和数据备份视为房间凭据加以保护。
保留同一个 Compose 项目与目录,以便继续使用原命名数据卷;
`docker compose down --volumes` 会删除这些数据。
回滚时,恢复之前的镜像设置与兼容配置,再重新创建服务。
跨越存储格式边界前,先阅读发布说明。裸机部署脚本 `release-app.sh` 不管理容器。
## 运行验证
- 本地和公网来源的 `/healthz` 都返回 `{"status":"ok"}`。
- Piik 与 HTTPS 代理处于运行状态,重启次数符合预期,日志和资源使用有界;
Piik 占有配置中的 STUN/SFU UDP 监听器。
- 官方 Trickle ICE 示例能从配置的 STUN 服务取得普通 UDP `srflx` 候选,不出现 relay 候选。
- Peer ICE 成功时,两个网络上的设备能在直连房间中显示媒体。
- 启用 SFU 时,受限路由通过内嵌 SFU UDP 创建一个受管理的房主发布,且只接纳已授权订阅;
Peer 后代节点继续使用普通 P2P。
- 阻断全部 UDP 后,在有限时间内进入明确的失败状态。
- 使用持久化模式时,重启 Piik 保留房间权威状态;浏览器会话和媒体从新进程状态重新连接。
代表性网络、移动端、音频、20 位观众和长时间运行的证据由
[验证状态](../verification-status.md)跟踪,不记录在本操作手册中。
+5 -6
View File
@@ -51,12 +51,11 @@ unit provides the SQLite state directory. Room ownership, invitations and privat
admission still apply. The [configuration reference](../reference/configuration.md)
owns each setting and its bounds.
For updates, verify the release descriptor and manifest before placing a new
release under `/opt/piik/releases/`. Stop Piik, back up its SQLite state, switch
`current` to the verified release, and restart. Check local/public health,
WebSocket room access and STUN. Keep the previous release and state backup until
acceptance; restore both with Piik stopped if rollback is needed. The nginx-specific
maintainer wrapper does not manage this Caddy installation.
For updates, follow the [application release boundary](../deployment.md#release-boundary)
and its artifact verification, health checks and recovery rules. Routine application
rollback restores the previous executable while keeping current room data. This
Caddy installation needs a scoped updater; the nginx-specific maintainer wrapper
does not manage it.
## Preview
+10 -1
View File
@@ -32,6 +32,10 @@ Browser picker and any exact App-owned native windows. It never chooses a
window automatically. A reproduced Browser-window, capture, or background
failure is required before introducing an embedded Web runtime.
Failure to open the system Browser does not retire a ready App service. The
console keeps its entry address and retry action; service failure and explicit
exit still own shutdown. Browser-opening errors retain their underlying cause.
Server and App share one control layout and vocabulary. Deployment capabilities
may fix a control on or off, but do not remove its place in the interface;
disabled controls explain their reason in text and pure-visual modes. Real
@@ -70,6 +74,9 @@ Chinese catalog across App, Server UI and website; this includes region and
script tags such as `zh-CN`, `zh-TW`, `zh-HK`, `zh-Hans` and `zh-Hant`.
Pure-visual mode remains an explicit choice. An explicit later choice is
persisted and takes precedence over that default, including App launch handoff.
Explicit launcher choices apply only to the presentation domains the user chose.
Older launchers cannot distinguish defaults from explicit choices; updated Sites
preserve their saved preferences when receiving those unmarked hints.
The App console uses its own supported presentation languages and falls back
to English for additional UI languages. Text modes use concise copy.
@@ -87,9 +94,11 @@ fullscreen includes the playback bar; Safari's video-only fullscreen uses
system controls and native video events to reflect playback and exit.
Identity, connection details and topology stay in the deck.
Replacement media bindings do not consult Viewer-local pause: when the Host is
Replacement video bindings do not consult Viewer-local pause: when the Host is
playing, they attempt playback subject to browser autoplay permission. There is
no separate remembered playback intent for source changes or reconnects.
Adding, replacing or removing audio on the same video binding updates only its
audio consumer; it preserves current-frame proof and Viewer-local playback.
The bar spans the screen's lower edge. Narrow screens separate audio controls
from window actions into two rows and retain 44px action targets. Playing video hides the bar
+24 -4
View File
@@ -27,12 +27,28 @@ service secret store or an untracked access-restricted environment file.
| `ALLOWED_ORIGINS` | Comma-separated exact HTTP(S) origins; wildcard is invalid. |
| `SITE_ACCESS_PASSWORD` | Optional in every environment. Unset or empty allows entry without a site password. A configured value is matched exactly, including spaces and Unicode; there are no password length or character rules. General HTTP request limits still apply. Room ownership and Viewer admission remain independent. |
| `ROOM_DATABASE_PATH` | Hosted defaults to `rooms.sqlite` in its working directory when unset or blank. An explicit absolute file path selects another SQLite file; `:memory:` opts into process-memory room authority. App Local remains in memory. |
| `MAX_VIEWERS_PER_ROOM` | `1..20`, default `8`. |
| `MAX_VIEWERS_PER_ROOM` | `1..20`, default `20`; excludes the Host. |
| `ENDPOINT_MEDIA_COPY_CAPACITY` | Shared endpoint steady-copy cap `1..3`, default `2`. |
| `STUN_URLS` | Comma-separated advertised `stun:` discovery URLs; at least one is required in production. These are not local bind addresses and may use an unproxied DNS name separate from the Web origin. |
| `STUN_LISTEN_HOST` | Hosted IPv4 STUN bind address, default `0.0.0.0` when `STUN_URLS` is configured; independent of HTTP `LISTEN_HOST`. Local App construction creates no STUN listeners. |
| `NAT_PREDICTION_ENABLED` | Optional bounded NAT prediction capability, default `false`; requires an ordinary `STUN_URLS` endpoint on UDP 3478. Hosted startup binds UDP 3479/3480 before advertising the capability. When unavailable, the visible NAT switch is locked off; ordinary ICE remains. Firewall reachability remains an operator requirement. |
### Room capacity
`MAX_VIEWERS_PER_ROOM` counts authenticated Viewers per room, excluding the Host.
Self-hosted Server accepts `1..20` and defaults to `20`, matching the App and
deployment examples. Apply changes by restarting the
Server (with Compose, run `docker compose up -d` to recreate it after editing
`.env`). Restart interrupts active connections; excess Viewers receive `ROOM_FULL`
when joining again. App Local and public-invite modes stay fixed at `20`.
Values above `20` are rejected at startup. This is also a wire-contract bound,
so increasing it in source requires a compatibility and capacity review. More
Viewers can add relay hops, routing wait and total media load. The setting does
not increase the per-endpoint copy cap or guarantee available bandwidth.
### Media fallback
Automatic SFU fallback runs inside the Hosted process when `SFU_UDP_PORT` is set:
| Variable | Contract |
@@ -77,9 +93,13 @@ schema field. The user settings are:
| `site` | Saved Piik Site origin. The launcher or `--site` updates it. |
| `localAccessPassword` | Empty by default. Optional password for the App's Local room authority, with the same exact-match behavior as `SITE_ACCESS_PASSWORD`. It is separate from a hosted site's password. |
The launcher preselects a saved Site when present; otherwise it preselects the
public invitation link. Selecting a mode does not start it: the user confirms
with the launch button. Local mode remains available for the same network.
The launcher remembers the last mode confirmed with the launch button. This
optional preference lives beside the configuration as `client.json.mode` (or
`<custom-config-path>.mode`); removing it restores the initial selection without
changing saved settings. Older App versions ignore this file. Without a valid
preference, a saved Site is selected when present; otherwise the public
invitation link is selected. Choosing a mode does not start it until confirmed.
Local mode remains available for the same network.
For Local mode, the launcher selects a sole active address or sole private IPv4
address automatically. With several choices it shows interface names and IPs;
an ambiguous choice must be selected before launch. `--lan-address` preselects
+1
View File
@@ -105,6 +105,7 @@ above rather than searching the tree.
| --- | --- |
| Host workflow, share lifecycle, notices | `src/client/pages/HostPage.tsx`, `src/client/pages/host-page-notices.ts` |
| Viewer workflow, playback, status projection | `src/client/pages/ViewerPage.tsx`, `src/client/media/viewer-presentation.ts`, `src/client/ui/media-status.ts` |
| Received viewer metrics and expiry | `src/client/media/viewer-quality-evidence.ts` owns evidence rules; `viewer-quality-evidence-store.ts` owns received entries and timers for Host and relay views |
| Browser peer and route mechanics | `src/client/webrtc/host-peer.ts`, `viewer-peer.ts`, `viewer-relay.ts`, `src/client/media/host-sfu-route.ts`, `viewer-sfu-route.ts`, `route-transition.ts` |
| Capture, profiles and sender parameters | `src/client/media/quality.ts`, `src/client/media/browser-encoding-pool.ts`, `browser-encoding-output.ts` |
| SFU endpoints | `src/client/sfu/publisher.ts`, `subscriber.ts`, `peer.ts`, `internal/server/sfu` |
+4
View File
@@ -62,6 +62,10 @@ are optional. Keep App and Server package names distinguishable at download link
Public platform notes say what has been tested and invite useful feedback;
internal phrases such as “this phase's acceptance target” belong in status.
Present the public online site as a usable entry for real rooms and sharing.
Offer the App download first and direct browser use alongside it; keep the
site's P2P-only deployment conditions in its detailed guide.
Use progressive disclosure on the website and in the film:
| Information layer | Reader's question | Content |
+5 -8
View File
@@ -1,6 +1,6 @@
# Current Status
Last updated: 2026-09-12
Last updated: 2026-09-15
This is the compact execution/deployment index. Product modules own behavior,
[verification status](./verification-status.md) owns unresolved physical limits,
@@ -9,8 +9,8 @@ and Git/PRs own completed history.
## Accepted Release Contract
- Browser/server v23, Native control v9 and capture v7 form the current contract.
The coordinated brand/protocol cutover requires matching Web/App/Server
artifacts and a reload of incompatible active pages.
[Versioning](./reference/versioning.md#public-compatibility-promise) owns
compatibility between published Web/App/Server versions.
- Hosted Server defaults to SQLite schema 2. Room authority has no inactivity
expiry; explicit replacement/deletion or grant rotation/revocation ends the
corresponding authority. Explicit memory mode and App Local end rooms at
@@ -24,12 +24,9 @@ and Git/PRs own completed history.
## Deployment
Source, package identity, GitHub repository and local worktree paths use Piik.
Runtime code keeps only the Piik wire labels and stored keys. The coordinated
private cutover requires matching App/Server/Web packages; unavailable old
Browser credentials cannot be reconstructed from stored digests.
Runtime code keeps only the Piik wire labels and stored keys.
The immutable release descriptor, runtime `REVISION` and operator deployment
record own exact identity and postflight results. Old active pages reload at
the signaling v23 boundary.
record own exact identity and postflight results.
The existing deployment uses one Go process for Web, room authority/signaling,
Binding-only STUN on UDP 3478/3479/3480 and optional SFU on UDP 7882. nginx owns
+30 -22
View File
@@ -1,43 +1,39 @@
# Current TODO Ledger
Last reviewed: 2026-09-14
Last reviewed: 2026-09-15
Only **Now** is executable. Product modules own behavior; Git/PRs own completed
history. A parked idea is not implementation authority.
## Now
- [ ] **Introduction motion and presentation boundaries.** Resolve the automatic
hero-loop control requirement against the accepted design without restoring
discarded gesture controls or silently removing animation. Review only the
remaining concrete layout/accessibility choices; distinguish decorative content
from functional symbols. Keep independent correctness repairs moving.
- [ ] **Post-launch monitoring.** Collect App/Server feedback and verify the
public downloads, container pulls and deployed services after product releases.
Follow the [deployment runbook](./deployment.md) for the private service and keep public
demo deployment separate. Preserve the owner's
[device/network deferrals](./verification-status.md#candidate-evidence-boundary).
- [ ] **Browser sharing startup failure.** Reproduce [#399](https://github.com/TNTcraftHIM/Piik/issues/399):
the local encoding connection can remain connecting without producing frames
or entering the existing ordinary-sender fallback. Verify bounded startup and
retirement at that owner before changing room routing or ICE configuration.
- [ ] **App startup feedback.** Verify [#396](https://github.com/TNTcraftHIM/Piik/issues/396)
against the current release. Its older build predates the LAN selector,
detailed launcher errors and Windows error-exit pause; those repairs do not
establish resolution on the reporting machine. Use a current Debug report if
startup still fails.
- [ ] **Chrome mirror download warning.** Verify the current Gitee download in
Chrome and resolve its download-source warning using the recorded verdict and
package checksums. Keep GitHub primary; ordinary link changes do not establish
that Safe Browsing has cleared the mirror.
- [ ] **App launch-mode preference.** Check why a saved Site keeps selecting
Site mode after the user chooses another mode. Review last-used-mode behavior
and its configuration owner before changing startup selection.
- [ ] **Display-name editing.** Reproduce the edit button immediately closing
or failing to open the editor. Check Host and Viewer forms, including native
submit behavior when React reuses action buttons, before changing signaling.
- [ ] **Theater centering.** Keep expansion and collapse centered. The current
width spring overshoots the viewport before settling; verify motion at wide
and narrow sizes without changing playback state.
- [ ] **Tooltip motion consistency.** Review all tooltip entries and explanatory
comics for missing motion across languages, themes and pointer/touch input.
Check trigger reuse and static rendering against the shared visual grammar;
resolve any intended change to settled-state motion in that owner.
- [ ] **Windows launcher exit after opening the page.** A user reports that the
mode-selection page opens, then the App console reports
`Piik App could not open its launcher: exit status 0xc0000005`.
Recheck on the reporting machine after the browser-handoff repair. The Windows
URL-handler crash itself still needs the affected build and process/dump
evidence; local checks cannot establish its underlying cause.
- [ ] **Tooltip placement around video.** After the current motion/meaning
acceptance, review placement that keeps the picture visible, including
side placement and restrained translucency. Keep neighbouring actions
reachable when a help popup remains open. Current behavior prefers above
(below for header controls) and flips for viewport space. Discuss the design
before changing placement rules.
Keep fixes on a maintenance branch until acceptance. The public release is the
compatibility baseline; private service deployment stays independent.
@@ -86,6 +82,8 @@ not establish better connection success or speed; this note adds no retry policy
candidate. Reopen C=3 structural-intent retention, SFU failure during unrelated
prepare and multi-child evidence ownership only with current-contract
reproductions.
When related behavior changes, compare the Browser/Native recovery budget;
preserve Native bridge versus network failure distinctions when sharing code.
No new revision namespace, failure-state mirror or topology queue by default.
5. **Storage fault recovery.** Choose and verify a damaged-disk/COMMIT/ROLLBACK
recovery policy before adding catch-and-continue or retries. This failure
@@ -114,3 +112,13 @@ not establish better connection success or speed; this note adds no retry policy
service. Sign Piik's executables before archive checksums are computed;
signing improves publisher identity but does not guarantee that antivirus
cloud scanning stops. Service selection and enrollment remain pending.
11. **Gitee download-source warning.** Paused by the owner. Keep GitHub primary
and retain Gitee; do not add a self-hosted mirror. Chrome still blocks the
Gitee attachment when Referer is removed. Reopen for new evidence or a
provider review; the warning remains unresolved.
12. **Windows capture border preference.** [#401](https://github.com/TNTcraftHIM/Piik/issues/401)
requests an optional borderless capture mode. First verify Windows'
permission-based `GraphicsCaptureAccess` / `IsBorderRequired` flow with the
packaged Native helper; keep ordinary capture working when unavailable or
denied. Browser capture indicators remain browser-owned. No new setting is
accepted yet.
+3 -1
View File
@@ -13,8 +13,10 @@
try { theme = localStorage.getItem("piik:ui-theme"); } catch (e) {}
var launch = new URLSearchParams(location.hash.slice(1));
var launchTheme = launch.get("piik-theme");
var explicitTheme = (new URLSearchParams(location.search).get("piik-preferences") || "").split(",").includes("theme");
if (launch.get("piik-client") === "1" &&
(launchTheme === "light" || launchTheme === "dark" || launchTheme === "system")) {
(launchTheme === "light" || launchTheme === "dark" || launchTheme === "system") &&
(explicitTheme || (theme !== "light" && theme !== "dark"))) {
theme = launchTheme === "system" ? systemTheme : launchTheme;
}
document.documentElement.dataset.theme =
+17 -18
View File
@@ -16,7 +16,6 @@ import (
"syscall"
"time"
"github.com/TNTcraftHIM/Piik/internal/app/browser"
appconfig "github.com/TNTcraftHIM/Piik/internal/app/config"
"github.com/TNTcraftHIM/Piik/internal/app/lan"
"github.com/TNTcraftHIM/Piik/internal/app/launcher"
@@ -224,11 +223,16 @@ func runLauncher(
control *loopback.Server,
configureDiagnostics func(*Options, appconfig.Config) error,
) error {
mode, err := launcher.LoadMode(configPath)
if err != nil {
slog.Warn("Could not restore the last launcher mode", diagnostics.Error(err))
}
launch, err := launcher.Start(
ctx,
webassets.FS(),
launcher.Options{
Site: config.Site, Version: buildVersion(), Revision: BuildRevision,
DefaultMode: mode,
Site: config.Site, Version: buildVersion(), Revision: BuildRevision,
LocalAccessPassword: config.LocalAccessPassword,
Debug: options.Debug, LANAddress: options.LANAddress,
},
@@ -246,9 +250,7 @@ func runLauncher(
launchAddress.Scheme+"://"+launchAddress.Host,
))
options.console.show(consoleView{state: "setup", entry: launch.URL()})
if err = browser.Open(launch.URL()); err != nil {
return fmt.Errorf("Piik App could not open its launcher: %w", err)
}
options.console.send(openBrowser(launch.URL()))
var selection launcher.Selection
select {
@@ -262,6 +264,9 @@ func runLauncher(
return nil
}
options.console.setLanguage(selection.Language)
if err := launcher.SaveMode(configPath, selection.Mode); err != nil {
slog.Warn("Could not save the launcher mode", diagnostics.Error(err))
}
if selection.Mode == launcher.ModeSite {
config.Site = selection.Site
} else {
@@ -390,21 +395,17 @@ func runSite(site string, options Options, control *loopback.Server) error {
var err error
slog.Debug("piik-client", "event", "mode", "mode", "site")
targetURL := launchURL(site)
view := consoleView{mode: "site", state: "starting", entry: targetURL}
view := consoleView{mode: "site", state: "ready", entry: targetURL}
options.console.show(view)
if options.console.machine {
fmt.Printf("Piik Site: %s\n", site)
}
if !options.DisableBrowser {
if err = browser.Open(targetURL); err != nil {
return fmt.Errorf("Piik App could not open the Site: %w", err)
}
options.console.send(openBrowser(targetURL))
} else if options.Ready != nil {
options.Ready(targetURL)
}
view.state = "ready"
slog.Debug("piik-client", "event", "site-ready")
options.console.show(view)
if err = <-control.Done(); err != nil {
return fmt.Errorf("Piik App native control stopped unexpectedly: %w", err)
}
@@ -507,18 +508,16 @@ func runLocal(ctx context.Context, options Options, config appconfig.Config,
fmt.Sprintf("http://127.0.0.1:%d/", options.Port),
config.LocalAccessPassword,
)
if !options.DisableBrowser {
if err = browser.Open(launchURL); err != nil {
return fmt.Errorf("Piik App could not open the Local page: %w", err)
}
} else if options.Ready != nil {
options.Ready(launchURL)
}
view.state, view.entry, view.invite = "ready", launchURL, publicOrigin
if view.invite == "" {
view.invite = fmt.Sprintf("http://%s:%d", selectedAddress, options.Port)
}
options.console.show(view)
if !options.DisableBrowser {
options.console.send(openBrowser(launchURL))
} else if options.Ready != nil {
options.Ready(launchURL)
}
var tunnelDone <-chan struct{}
if tunnel != nil {
tunnelDone = tunnel.Done()
+81
View File
@@ -2,6 +2,8 @@ package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log"
@@ -18,10 +20,89 @@ import (
appconfig "github.com/TNTcraftHIM/Piik/internal/app/config"
"github.com/TNTcraftHIM/Piik/internal/app/lan"
"github.com/TNTcraftHIM/Piik/internal/app/launcher"
"github.com/TNTcraftHIM/Piik/internal/app/loopback"
serverconfig "github.com/TNTcraftHIM/Piik/internal/server/config"
)
func TestBrowserOpenFailureKeepsTheAppAvailable(t *testing.T) {
// An empty launcher search path makes the real browser.Open fail on every
// supported platform without changing the user's default browser or opening it.
t.Setenv("PATH", t.TempDir())
t.Setenv("TERM", "dumb")
for _, mode := range []string{"launcher", "site"} {
t.Run(mode, func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
control, err := loopback.Start(ctx, loopback.Options{})
if err != nil {
t.Fatal(err)
}
defer control.Close()
console := newConsole(cancel, false)
options := Options{Port: 8787, console: console}
configPath := filepath.Join(t.TempDir(), "client.json")
config, err := appconfig.LoadOrCreate(configPath)
if err != nil {
t.Fatal(err)
}
done := make(chan error, 1)
go func() {
if mode == "site" {
done <- runSite("https://share.example", options, control)
} else {
done <- runLauncher(ctx, options, configPath, config, control,
func(*Options, appconfig.Config) error { return nil })
}
}()
t.Cleanup(func() {
cancel()
select {
case err := <-done:
if err != nil {
t.Errorf("App exit: %v", err)
}
case <-time.After(5 * time.Second):
t.Error("App did not stop after cancellation")
}
})
var view consoleView
deadline := time.After(3 * time.Second)
for view.browserError == nil {
console.mu.Lock()
view = console.plain.view
console.mu.Unlock()
select {
case <-deadline:
t.Fatal("browser failure was not reported")
case <-time.After(10 * time.Millisecond):
}
}
if view.entry == "" || view.state != "setup" && view.state != "ready" {
t.Fatalf("unusable App after browser failure: %+v", view)
}
if mode == "launcher" {
client := &http.Client{Timeout: 3 * time.Second}
origin := strings.TrimSuffix(view.entry, "/client")
response, err := client.Post(origin+"/api/client-launcher/launch", "application/json",
strings.NewReader(`{"mode":"site","language":"en","site":"https://share.example"}`))
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
var result struct{ Target string }
if json.NewDecoder(response.Body).Decode(&result) != nil || response.StatusCode != http.StatusOK ||
result.Target != launchURL("https://share.example") {
t.Fatalf("manual launch failed: status=%d, target=%q", response.StatusCode, result.Target)
}
if saved, err := launcher.LoadMode(configPath); err != nil || saved != launcher.ModeSite {
t.Fatalf("manual selection was not saved: %q, %v", saved, err)
}
}
})
}
}
func TestSavedSiteAllowsBrowserOriginAtNativeControl(t *testing.T) {
for _, site := range []struct{ input, origin string }{
{"https://Share.Example:443", "https://share.example"},
+31 -7
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"io"
"log/slog"
"math/rand/v2"
"net/url"
"os"
@@ -15,6 +16,7 @@ import (
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/TNTcraftHIM/Piik/internal/app/browser"
"github.com/TNTcraftHIM/Piik/internal/diagnostics"
"github.com/charmbracelet/colorprofile"
"github.com/charmbracelet/x/ansi"
"github.com/charmbracelet/x/term"
@@ -24,13 +26,17 @@ type consoleView struct {
mode, state, entry, invite string
protected bool
problem string
browserError error
}
type consoleLanguage string
type consoleFinished struct{ err error }
type consoleTick struct{}
type consoleIdleWink struct{}
type consoleOpenResult struct{ err error }
type consoleOpenResult struct {
target string
err error
}
type consoleDiagnostic string
type consoleDebug struct {
logPath string
@@ -136,6 +142,16 @@ func (console *console) send(message tea.Msg) {
func (console *console) show(view consoleView) { console.send(view) }
func (console *console) setLanguage(language string) { console.send(consoleLanguage(language)) }
// Opening a page is a convenience action; the launcher and room services own
// readiness and shutdown even when the system URL handler fails.
func openBrowser(target string) consoleOpenResult {
err := browser.Open(target)
if err != nil {
slog.Warn("Could not open the system browser", diagnostics.Error(err))
}
return consoleOpenResult{target: target, err: err}
}
func (console *console) finish(err error) error {
console.send(consoleFinished{err})
if console.program != nil {
@@ -177,6 +193,7 @@ func (model consoleModel) Update(message tea.Msg) (tea.Model, tea.Cmd) {
model.language = string(value)
case consoleDiagnostic:
model.view.problem = string(value)
model.view.browserError = nil
case consoleDebug:
model.debug = value
case consoleExportResult:
@@ -200,12 +217,13 @@ func (model consoleModel) Update(message tea.Msg) (tea.Model, tea.Cmd) {
model.frame, model.idleWink = 8, true
}
case consoleOpenResult:
if value.err != nil {
model.view.problem = model.text("openFailed")
if value.target == model.view.entry && !model.finished && model.view.state != "stopping" {
model.view.browserError = value.err
}
case consoleFinished:
model.finished = true
model.view.entry, model.view.invite = "", ""
model.view.browserError = nil
model.view.state, model.view.problem = "stopped", ""
if value.err != nil {
model.view.state, model.view.problem = "failed", value.err.Error()
@@ -216,9 +234,9 @@ func (model consoleModel) Update(message tea.Msg) (tea.Model, tea.Cmd) {
case "q", "ctrl+c":
model.view = consoleView{mode: model.view.mode, state: "stopping"}
model.cancel()
case "o":
case "o", "O":
if target := model.view.entry; target != "" {
return model, func() tea.Msg { return consoleOpenResult{browser.Open(launchURL(consoleAddress(target)))} }
return model, func() tea.Msg { return openBrowser(target) }
}
case "d", "D":
if model.debug.export != nil && !model.exporting && !model.finished && model.view.state != "stopping" {
@@ -360,8 +378,12 @@ func (model consoleModel) content(styled bool) string {
fmt.Fprintf(&out, "\n%s %s\n", muted.Render(model.text("access")), model.text(access))
}
}
if model.view.problem != "" {
lines := strings.Split(ansi.Hardwrap(ansi.Strip(model.view.problem), width-4, true), "\n")
problem := model.view.problem
if model.view.browserError != nil {
problem = model.text("openFailed") + "\n" + model.view.browserError.Error()
}
if problem != "" {
lines := strings.Split(ansi.Hardwrap(ansi.Strip(problem), width-4, true), "\n")
if len(lines) > 3 {
lines = append(lines[:2], "...")
}
@@ -443,6 +465,8 @@ func consoleVisualToken(key string) string {
return "○"
case "failed", "error":
return "!"
case "openFailed":
return "! ↗"
case "open":
return "○"
case "password":
+36
View File
@@ -25,6 +25,42 @@ func TestConsoleStartsWithSystemLanguageAndAcceptsVisualSelection(t *testing.T)
}
}
func TestBrowserOpenFeedbackBelongsToTheCurrentEntry(t *testing.T) {
target := "http://127.0.0.1:8787/#client-access=private-secret"
failure := consoleOpenResult{target: target, err: errors.New("exit status 0xc0000005")}
model := consoleModel{width: 80, language: "en", view: consoleView{state: "ready", entry: target}}
updated, _ := model.Update(failure)
model = updated.(consoleModel)
if model.finished || model.view.state != "ready" || model.view.entry != target {
t.Fatal("browser failure retired a ready App")
}
for _, language := range []string{"en", "zh", "vis"} {
updated, _ = model.Update(consoleLanguage(language))
model = updated.(consoleModel)
content := model.content(false)
if !strings.Contains(content, model.text("openFailed")) || !strings.Contains(content, "0xc0000005") ||
strings.Contains(content, "private-secret") {
t.Fatalf("browser feedback lost its current language/error or exposed a credential: %s", content)
}
}
updated, _ = model.Update(consoleOpenResult{target: target})
model = updated.(consoleModel)
if model.view.browserError != nil {
t.Fatal("successful retry retained the browser failure")
}
for _, next := range []tea.Msg{
consoleView{state: "setup", entry: "http://127.0.0.1:12345/client"},
consoleView{state: "stopping"},
consoleFinished{err: errors.New("service failed")},
} {
updated, _ = model.Update(next)
updated, _ = updated.(consoleModel).Update(failure)
if current := updated.(consoleModel); current.view.browserError != nil {
t.Fatal("late browser result overwrote the current App state")
}
}
}
func TestConsolePresentationAndShutdown(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
+35
View File
@@ -0,0 +1,35 @@
package launcher
import (
"errors"
"os"
"strings"
)
// Keep UI preference outside client.json: published App versions reject
// unknown configuration fields. Missing preference preserves their default.
func LoadMode(configPath string) (Mode, error) {
payload, err := os.ReadFile(configPath + ".mode")
if errors.Is(err, os.ErrNotExist) {
return "", nil
}
if err != nil {
return "", err
}
mode := Mode(strings.TrimSpace(string(payload)))
if !validMode(mode) {
return "", errors.New("invalid saved launcher mode")
}
return mode, nil
}
func SaveMode(configPath string, mode Mode) error {
if !validMode(mode) {
return errors.New("invalid launcher mode")
}
return os.WriteFile(configPath+".mode", []byte(string(mode)+"\n"), 0o600)
}
func validMode(mode Mode) bool {
return mode == ModeLocal || mode == ModeLink || mode == ModeSite
}
+81
View File
@@ -0,0 +1,81 @@
package launcher
import (
"encoding/json"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
appconfig "github.com/TNTcraftHIM/Piik/internal/app/config"
)
func TestSavedModeSurvivesRelaunchWithoutChangingLegacyConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), "client.json")
config, err := appconfig.LoadOrCreate(path)
if err != nil {
t.Fatal(err)
}
config.Site = "https://share.example"
if err := appconfig.Save(path, config); err != nil {
t.Fatal(err)
}
original, _ := os.ReadFile(path)
for _, test := range []struct {
chosen Mode
site string
want Mode
}{
{ModeSite, config.Site, ModeSite},
{ModeLocal, config.Site, ModeLocal},
{ModeLink, config.Site, ModeLink},
{ModeSite, "", ModeLink},
} {
if err := SaveMode(path, test.chosen); err != nil {
t.Fatal(err)
}
mode, err := LoadMode(path)
if err != nil || mode != test.chosen {
t.Fatalf("restored mode = %q, %v", mode, err)
}
server, err := Start(t.Context(), appAssets(), Options{Site: test.site, DefaultMode: mode})
if err != nil {
t.Fatal(err)
}
response, err := http.Get(strings.TrimSuffix(server.URL(), "/client") + "/api/client-launcher")
if err != nil {
t.Fatal(err)
}
var state struct {
DefaultMode Mode `json:"defaultMode"`
Site string `json:"site"`
}
err = json.NewDecoder(response.Body).Decode(&state)
response.Body.Close()
server.Close()
if err != nil || state.DefaultMode != test.want || state.Site != test.site {
t.Fatalf("launcher lost mode or saved Site: %+v, %v", state, err)
}
}
current, _ := os.ReadFile(path)
if string(current) != string(original) {
t.Fatal("launcher preference changed legacy configuration")
}
}
func TestUnavailableModePreferenceDoesNotSupplyAnInvalidChoice(t *testing.T) {
path := filepath.Join(t.TempDir(), "client.json")
if mode, err := LoadMode(path); err != nil || mode != "" {
t.Fatalf("missing preference = %q, %v", mode, err)
}
if err := os.WriteFile(path+".mode", []byte("invalid"), 0o600); err != nil {
t.Fatal(err)
}
if mode, err := LoadMode(path); err == nil || mode != "" {
t.Fatalf("corrupt preference = %q, %v", mode, err)
}
if err := SaveMode(path, "invalid"); err == nil {
t.Fatal("saved an invalid mode")
}
}
+6
View File
@@ -47,6 +47,7 @@ type Selection struct {
}
type Options struct {
DefaultMode Mode
Site string
Version string
Revision string
@@ -65,6 +66,7 @@ type Server struct {
assets fs.FS
static http.Handler
site string
defaultMode Mode
localAccessPassword string
version string
revision string
@@ -115,6 +117,7 @@ func Start(parent context.Context, assets fs.FS, options Options) (*Server, erro
assets: assets,
static: http.FileServer(http.FS(assets)),
site: normalizedSite,
defaultMode: options.DefaultMode,
localAccessPassword: options.LocalAccessPassword,
version: options.Version,
revision: strings.TrimSpace(options.Revision),
@@ -238,6 +241,9 @@ func (server *Server) handleState(response http.ResponseWriter, request *http.Re
"debug": server.debug,
"lan": map[string]any{"addresses": addresses, "selected": selected},
"defaultMode": func() Mode {
if validMode(server.defaultMode) && (server.defaultMode != ModeSite || server.site != "") {
return server.defaultMode
}
if server.site != "" {
return ModeSite
}
+1 -1
View File
@@ -28,7 +28,7 @@ const (
// Bounds and defaults for Hosted configuration.
const (
defaultMaxViewersPerRoom = 8
defaultMaxViewersPerRoom = protocol.MaxViewersPerRoomLimit
defaultPort = 8787
maxPort = 65_535
)
+2 -2
View File
@@ -84,7 +84,7 @@ func TestLoadDevelopmentDefaults(t *testing.T) {
if config.NATPredictionSTUNURLs != nil {
t.Errorf("NATPredictionSTUNURLs = %v, want nil (TS left it undefined)", config.NATPredictionSTUNURLs)
}
if config.MaxViewersPerRoom != 8 {
if config.MaxViewersPerRoom != 20 {
t.Errorf("MaxViewersPerRoom = %d", config.MaxViewersPerRoom)
}
if config.EndpointMediaCopyCapacity != 2 {
@@ -203,7 +203,7 @@ func TestLoadAccepts(t *testing.T) {
check func(*testing.T, Config)
}{
{"default viewer limit", map[string]string{}, func(t *testing.T, c Config) {
if c.MaxViewersPerRoom != 8 {
if c.MaxViewersPerRoom != 20 {
t.Errorf("MaxViewersPerRoom = %d", c.MaxViewersPerRoom)
}
}},
+1 -1
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<style>svg { color: #182535; } @media (prefers-color-scheme: dark) { svg { color: #dfe8f2; } }</style>
<style>svg { color: #22303e; } @media (prefers-color-scheme: dark) { svg { color: #dde8f3; } }</style>
<path d="m11 4 5 4 5-4"/>
<rect x="4" y="8" width="24" height="18" rx="4"/>
<circle cx="11.5" cy="16.5" r="1.25" fill="currentColor" stroke="none"/>

Before

Width:  |  Height:  |  Size: 490 B

After

Width:  |  Height:  |  Size: 490 B

+4 -4
View File
@@ -1,10 +1,10 @@
# Piik film
The homepage expands this optional, 76-second introduction in an iframe loaded
only on request. Closing it unloads playback; the adjacent **Open film page** link
opens the same player on its own page and carries the current language and theme.
Use `/film/?lang=en` for an English direct link. From the repository
root, run `npm run build:website` and `npm run preview:website`, then open
only on request. Closing it unloads playback; the frame follows the homepage's
language and theme. The player document also supports local recording and
focused checks. From the repository root, run `npm run build:website` and
`npm run preview:website`, then open
`http://127.0.0.1:18890/film/`. Publish the static `build/site/` output. A project
prefix such as `/Piik/film/` also works; GitHub Pages needs no application server.
+2 -2
View File
@@ -211,7 +211,7 @@ export function createArt(svg, language, onUI = () => {}) {
${rect(-115,383,950,200,0,INK)}${text(57,538,say('LOW LATENCY.','低延迟。'),zh?160:106,PAPER)}
</g>
${sceneLabel(say('PIIK / FEATURES', 'PIIK / 产品特点'))}
${text(65,793,say('A direct path, so friends can keep up.','画面优先直达朋友,精彩及时跟上。'),30,INK,'style="letter-spacing:0;font-weight:650"')}
${text(65,793,say('A direct path first, so friends can keep up.','画面优先直达朋友,精彩及时跟上。'),30,INK,'style="letter-spacing:0;font-weight:650"')}
</g>
<g id="feature-encode">${field(YELLOW)}
<path d="M1200-100h580v1100H871Z" fill="${ORANGE}"/>
@@ -229,7 +229,7 @@ export function createArt(svg, language, onUI = () => {}) {
${rect(-110,388,945,197,0,INK)}${text(48,541,say('SHARING.','轻负担。'),zh?164:153,PAPER)}
</g>
${sceneLabel(say('PIIK / FEATURES', 'PIIK / 产品特点'))}
${text(65,793,say('Lighter on resources, with more power left for games and creative work.','少占些资源,给游戏和创作多留些性能。'),30,INK,'style="letter-spacing:0;font-weight:650"')}
${text(65,793,say('Reuse picture processing where supported, for efficient sharing with friends.','能共用的画面处理,就不重复做;多人分享也讲效率。'),30,INK,'style="letter-spacing:0;font-weight:650"')}
</g>
<g id="feature-devices">${field(INK)}
<path d="M1210-80h480v1060H908Z" fill="${MINT}"/>
+2 -1
View File
@@ -7,6 +7,7 @@
body { margin:0; background:var(--wall); color:var(--ink); font:15px/1.6 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif; -webkit-font-smoothing:antialiased; }
[hidden] { display:none !important; }
html[lang="en"] [lang="zh-CN"], html[lang="zh-CN"] [lang="en"] { display:none; }
html .no-script [lang] { display:inline; }
button,a,input { -webkit-tap-highlight-color:transparent; touch-action:manipulation; }
button,a { color:inherit; }
button { font:inherit; cursor:pointer; }
@@ -18,7 +19,7 @@ button,a,input { outline-offset:5px; }
.sr-only { position:absolute; width:1px; height:1px; margin:-1px; padding:0; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
.masthead { width:calc(100% - 96px); max-width:1600px; margin:auto; height:90px; display:flex; align-items:center; justify-content:space-between; gap:20px; }
.masthead nav { display:flex; align-items:center; gap:28px; font-size:13px; font-weight:650; }
.masthead nav a,.masthead button { min-height:44px; display:inline-flex; align-items:center; text-decoration:none; }
.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; }
+4 -4
View File
@@ -17,7 +17,7 @@
<body>
<header class="masthead">
<a class="identity" href="../" data-home data-brand aria-label="Piik">Piik<span class="identity-dot">.</span></a>
<nav aria-label="Navigation / 导航">
<nav aria-label="Navigation">
<button id="language" type="button" hidden>简体中文</button>
<a href="../#download" data-home><span lang="en">Download</span><span lang="zh-CN">下载</span><span aria-hidden="true"> ↗</span></a>
<a href="../" data-home><span lang="en">Explore Piik</span><span lang="zh-CN">回到官网</span></a>
@@ -59,11 +59,11 @@
<p><span lang="en">Screen sharing for friends. Free &amp; open source.</span><span lang="zh-CN">和朋友分享屏幕,免费开源。</span></p>
<button id="about" type="button" hidden><span lang="en">Behind the scenes</span><span lang="zh-CN">幕后花絮</span><span aria-hidden="true"> ↗</span></button>
</div>
<p class="sr-only"><span lang="en">Piik shares your screen with friends. Download and open the App, choose Public invite, select a window or screen, then copy the invitation link and send it to your friends. Viewers watch in a browser. The film uses Piik's actual interface with sample content. An original game montage shows exploration, first-person shooting, a platform jump, a tactical flank, a skill-shot counterattack and a fighting-game block and counter. Friends cheer after the final hit. One host, up to twenty viewers. Piik is free and open source under the MIT license, ready for you to use and improve. It prefers direct connections to help friends keep up with the action. Sharing is lighter on resources, with more power left for games and creative work. Friends can open the invite in a desktop or mobile browser. Share a game, a drawing, travel photos or a movie.</span><span lang="zh-CN">Piik 用来和朋友分享屏幕。下载并打开 App,选择“公网邀请”,选好要分享的窗口或屏幕,再把邀请链接发给朋友。观众用浏览器就能看。短片展示了 Piik 的实际界面,使用示例内容。原创游戏蒙太奇依次展示探索、第一人称射击、横版跳跃、战术绕后、技能反击,以及格斗中的格挡和反击;最后一击命中后,朋友才喊出“漂亮!”。一位房主,最多二十位观众。Piik 免费开源,采用 MIT 许可,可以按自己的想法改进。画面优先直达朋友,精彩及时跟上。少占些资源,给游戏和创作多留些性能。朋友用电脑或手机浏览器点开邀请链接就能看。游戏、绘画、旅行照片和电影,都能分享给朋友看。</span></p>
<noscript><p class="no-script">This film needs JavaScript. <a href="../">Explore Piik / 回到官网</a></p></noscript>
<p class="sr-only"><span lang="en">Piik shares your screen with friends. Download and open the App, choose Public invite, select a window or screen, then copy the invitation link and send it to your friends. Viewers watch in a browser. The film uses Piik's actual interface with sample content. An original game montage shows exploration, first-person shooting, a platform jump, a tactical flank, a skill-shot counterattack and a fighting-game block and counter. Friends cheer after the final hit. One host, up to twenty viewers. Piik is free and open source under the MIT license, ready for you to use and improve. It prefers direct connections to help friends keep up with the action. It reuses picture processing where supported, for efficient sharing with friends. Friends can open the invite in a desktop or mobile browser. Share a game, a drawing, travel photos or a movie.</span><span lang="zh-CN">Piik 用来和朋友分享屏幕。下载并打开 App,选择“公网邀请”,选好要分享的窗口或屏幕,再把邀请链接发给朋友。观众用浏览器就能看。短片展示了 Piik 的实际界面,使用示例内容。原创游戏蒙太奇依次展示探索、第一人称射击、横版跳跃、战术绕后、技能反击,以及格斗中的格挡和反击;最后一击命中后,朋友才喊出“漂亮!”。一位房主,最多二十位观众。Piik 免费开源,采用 MIT 许可,可以按自己的想法改进。画面优先直达朋友,精彩及时跟上。能共用的画面处理,就不重复做;多人分享也讲效率。朋友用电脑或手机浏览器点开邀请链接就能看。游戏、绘画、旅行照片和电影,都能分享给朋友看。</span></p>
<noscript><p class="no-script">This film needs JavaScript. <span lang="zh-CN">播放短片需要启用 JavaScript。</span> <a href="../">Explore Piik / <span lang="zh-CN">回到官网</span></a></p></noscript>
</main>
<dialog id="credits" aria-labelledby="credits-title">
<form method="dialog"><button class="close" aria-label="Close / 关闭">×</button></form>
<form method="dialog"><button class="close" aria-label="Close">×</button></form>
<h2 id="credits-title"><span lang="en">Behind the scenes</span><span lang="zh-CN">幕后花絮</span></h2>
<h3><span lang="en">Drawn for Piik</span><span lang="zh-CN">为 Piik 画的小世界</span></h3>
<p><span lang="en">The game, photo and movie scenes are original illustrations. The App demonstration uses Piik's actual controls with sample rooms and participants.</span><span lang="zh-CN">游戏、照片和电影场景都是为 Piik 绘制的;App 演示使用真实控件,搭配示例房间和昵称。</span></p>
+13
View File
@@ -137,8 +137,16 @@ function toggleSound() {
function localize() {
const language = byId('language');
language.textContent = say('简体中文', 'English');
language.lang = say('zh-CN', 'en');
language.setAttribute('aria-label', say('切换到简体中文', 'Switch to English'));
document.title = say('Piik — Share the good stuff.', 'Piik — 来,看点好康的。');
document.querySelector('meta[name="description"]').content = say(
'Share the good stuff. See how Piik shares games, movies, drawings and photos with friends, in a 76-second film.',
'来,看点好康的。用一支 76 秒短片,看看如何用 Piik 和朋友分享游戏、电影、绘画和照片。',
);
document.querySelector('.masthead nav').setAttribute('aria-label', say('Navigation', '导航'));
byId('credits').querySelector('.close').setAttribute('aria-label', say('Close', '关闭'));
syncThemeColor();
document.querySelectorAll('[data-home]').forEach((link) => {
const url = new URL(link.getAttribute('href'), location.href);
url.searchParams.set('lang', root.lang);
@@ -150,6 +158,11 @@ function localize() {
showState();
}
function syncThemeColor() {
document.querySelector('meta[name="theme-color"]').content = getComputedStyle(root).getPropertyValue('--wall').trim();
}
matchMedia('(prefers-color-scheme: dark)').addEventListener('change', syncThemeColor);
byId('start').addEventListener('click', () => { void play(true, true); byId('play').focus({ preventScroll: true }); });
byId('start-muted').addEventListener('click', () => { void play(false, true); byId('play').focus({ preventScroll: true }); });
byId('play').addEventListener('click', () => playing ? pause() : void play());
+44 -12
View File
@@ -25,7 +25,13 @@ import {
VisGlyph,
} from "../../../src/client/components/living/primitives";
import { setCopy, useCopy } from "../../../src/client/ui/copy";
import { deriveParticipantStatus } from "../../../src/client/ui/media-status";
import { deriveHostStatus, deriveParticipantStatus, deriveViewerStatus } from "../../../src/client/ui/media-status";
import {
deriveViewerPresentation,
INITIAL_VIEWER_PRESENTATION_STATE,
reduceViewerPresentation,
type ViewerPresentationAction,
} from "../../../src/client/media/viewer-presentation";
import { gameMarkup, createGame } from "../../assets/game.js";
import { sketchMarkup } from "../../assets/sketch.js";
import { BEAT, INVITE_CUES } from "../score.js";
@@ -47,6 +53,20 @@ const status = deriveParticipantStatus(
{ mediaReady: true, upstream: { kind: "peer", peerId: host } },
true,
);
const viewerActions: ViewerPresentationAction[] = [
{ type: "access", access: "ready" },
{ type: "signal", signal: "connected" },
{ type: "host", host: "online" },
{ type: "route", revision: 1, phase: "active", kind: "p2p" },
{ type: "media-bound", generation: 1, revision: 1 },
{ type: "connection", revision: 1, connection: "connected" },
{ type: "frame-presented", generation: 1, proofEpoch: 0, revision: 1 },
];
const viewerTelevision = deriveViewerStatus(
deriveViewerPresentation(viewerActions.reduce(reduceViewerPresentation, INITIAL_VIEWER_PRESENTATION_STATE)),
"connected",
"p2p",
).television;
let drawGame: ((time: number) => void) | undefined;
function Game() {
const ref = useRef<SVGSVGElement>(null);
@@ -131,6 +151,12 @@ function Screen({ shot }: { shot: Shot }) {
const launcher = shot === "local" || shot === "link";
const chat = shot === "chat" || shot === "draft" || shot === "sent";
const live = shot === "host" || shot === "copied" || shot === "viewer" || chat;
const hostStatus = deriveHostStatus({
phase: live ? "live" : "idle",
paused: false,
signal: live ? "connected" : "offline",
roomReady: true,
});
const name = lang === "zh" ? "摸鱼办主任" : "ThisIsFine";
const names =
lang === "zh" ? ["派大星", "大聪明", "咸鱼突刺"] : ["Leeroy", "Kenobi", "NotABot"];
@@ -140,10 +166,9 @@ function Screen({ shot }: { shot: Shot }) {
led={
launcher ? undefined : (
<LedStrip
state={live ? "live" : "off"}
label={t(
live ? "state.presence.online" : "state.presence.offline",
)}
state={hostStatus.connection.tone}
comic={hostStatus.connection.comic}
label={t(hostStatus.connection.labelKey)}
/>
)
}
@@ -167,7 +192,7 @@ function Screen({ shot }: { shot: Shot }) {
live={live}
hasEntry={!live}
label={t("host.stageAria")}
indicator={live ? <StatusIndicator status={status} /> : undefined}
indicator={<StatusIndicator status={shot === "viewer" ? viewerTelevision : hostStatus.television} />}
>
{live ? <Game /> : <StaticNoise />}
{shot === "picker" && (
@@ -211,7 +236,7 @@ function Screen({ shot }: { shot: Shot }) {
<div className="lr-stage-notices" />
<Couch
view={shot === "viewer" ? "viewer" : "host"}
host={{ key: host, name, online: true, you: shot !== "viewer" }}
host={{ key: host, name, you: shot !== "viewer" }}
entries={
shot === "viewer"
? guests.map((key, i) => ({
@@ -232,24 +257,26 @@ function Screen({ shot }: { shot: Shot }) {
</RowGroup>
<span className="lr-spacer" />
<NameTag name={shot === "viewer" ? names[0] : name} identity={shot === "viewer" ? guests[0] : host} />
<Btn icon="pencil" cap="common.edit" title="host.nameEdit" />
<Btn icon="gauge" cap="host.details" title="host.details" />
<Btn icon="pencil" cap="common.edit" title="host.nameEdit" hint="hint-rename" />
<Btn icon="gauge" cap="host.details" title="host.details" hint="hint-details" />
<Btn
icon="network"
cap="host.topology"
title="host.topology.show"
hint="hint-topology"
/>
</Row>
{live && shot !== "viewer" ? (
<>
<Row>
<Btn icon="pause" cap="host.pause" title="host.pause" />
<Btn icon="square" cap="host.stop" title="host.stop" />
<Btn icon="pause" cap="host.pause" title="host.pause" hint="hint-pause" />
<Btn
icon="share"
icon="switchSource"
cap="host.switchSource"
title="host.switchSource"
hint="hint-switch-source"
/>
<Btn icon="stop" tone="danger" cap="host.stop" title="host.stop" hint="hint-share-stop" />
</Row>
<Row label={t("host.invite")}>
<RowGroup actions>
@@ -259,16 +286,21 @@ function Screen({ shot }: { shot: Shot }) {
title={
shot === "copied" || chat ? "common.copied" : "host.invite.copy"
}
hint="hint-copy-invite"
hintTone={shot === "copied" || chat ? "live" : undefined}
hintMotion={shot === "copied" || chat ? "still" : undefined}
/>
<Btn
icon="refresh"
cap="host.invite.rotateShort"
title="host.invite.rotate"
hint="hint-rotate-invite"
/>
<Btn
icon="linkOff"
cap="host.invite.revokeShort"
title="host.invite.revoke"
hint="hint-revoke-invite"
/>
</RowGroup>
<input
+41 -51
View File
@@ -40,9 +40,9 @@
<header class="site-header wrap">
<a class="identity" href="#" aria-label="Piik" data-brand>Piik<span class="identity-dot">.</span></a>
<nav aria-label="Main / 主导航">
<nav aria-label="Main">
<a href="#start"><span lang="en">Get started</span><span lang="zh-CN">使用教程</span></a>
<a href="#demo"><span lang="en">Demo</span><span lang="zh-CN">在线演示</span></a>
<a href="#demo"><span lang="en">Use online</span><span lang="zh-CN">在线使用</span></a>
<a href="#faq">FAQ</a>
<a href="#download"><span lang="en">Download</span><span lang="zh-CN">下载</span></a>
<a href="#docs"><span lang="en">Docs</span><span lang="zh-CN">文档</span></a>
@@ -82,9 +82,9 @@
<a class="button primary" href="#download"
><span lang="en">Download Piik App</span><span lang="zh-CN">下载 Piik App</span
><svg aria-hidden="true"><use href="#download-mark" /></svg></a
><a class="text-link" href="#start"
><span lang="en">Read the quick start</span><span lang="zh-CN">查看使用教程</span
><span aria-hidden="true"> →</span></a
><a class="text-link" href="https://demo.piik.tv" target="_blank" rel="noopener noreferrer"
><span lang="en">Use Piik online</span><span lang="zh-CN">直接使用在线版</span
><span aria-hidden="true"> ↗</span></a
>
</div>
<p class="hero-note">
@@ -97,9 +97,6 @@
<span lang="en">A little film about Piik</span><span lang="zh-CN">用一支短片,认识 Piik</span>
<span class="film-length">76s</span>
</a>
<a class="film-page-link" href="./film/" data-film-page>
<span lang="en">Open film page</span><span lang="zh-CN">独立页面观看</span><span aria-hidden="true"> ↗</span>
</a>
</div>
</div>
<div class="hero-scene">
@@ -120,7 +117,7 @@
<details class="film-preview wrap" id="film-preview" hidden>
<summary><span lang="en">A little film about Piik</span><span lang="zh-CN">用一支短片,认识 Piik</span><span class="film-collapse"><span lang="en">Close</span><span lang="zh-CN">收起</span> ×</span></summary>
<iframe id="website-film" title="Piik introduction / Piik 宣传片" allow="fullscreen" allowfullscreen></iframe>
<iframe id="website-film" title="Piik introduction" allow="fullscreen" allowfullscreen></iframe>
</details>
<section class="how-section" id="how" aria-labelledby="how-title">
@@ -254,18 +251,16 @@
</h2>
<p>
<span lang="en"
>Try the online demo first, or download the App to create your own room.<br />If Piik shows icons only,
select <strong>EN</strong> in its header to show English labels.</span
>Download the App to start sharing, or open <a href="https://demo.piik.tv">Piik online</a> and use it right in your browser.</span
><span lang="zh-CN"
>想先体验?从在线 Demo 开始;想自己开房间,可以下载客户端。<br />如果 Piik
只显示图标,点击顶部的<strong>中</strong>即可显示中文按钮文字。</span
>下载客户端,开始分享;也可以直接打开<a href="https://demo.piik.tv">在线版</a>,用浏览器开房间。</span
>
</p>
</div>
<nav class="guide-links" aria-label="Guides / 教程目录">
<a href="#demo"><span lang="en">Online demo</span><span lang="zh-CN">在线 Demo</span></a
<nav class="guide-links" aria-label="Guides">
<a href="#demo"><span lang="en">Use online</span><span lang="zh-CN">在线版</span></a
><a href="#guide-app"><span lang="en">Piik App</span><span lang="zh-CN">客户端</span></a
><a href="#guide-web"><span lang="en">Existing Piik site</span><span lang="zh-CN">网页版</span></a
><a href="#guide-web"><span lang="en">Existing Piik site</span><span lang="zh-CN">已有 Piik 站点</span></a
><a href="#watch"><span lang="en">Join as a viewer</span><span lang="zh-CN">加入房间</span></a>
</nav>
<div class="guides">
@@ -273,10 +268,10 @@
<summary>
<span class="guide-number">01</span
><span class="guide-heading"
><span lang="en">Try the online demo</span><span lang="zh-CN">体验在线 Demo</span
><span lang="en">Share online</span><span lang="zh-CN">直接在线分享</span
><small
><span lang="en">Open in your browser and start sharing</span
><span lang="zh-CN">在浏览器打开,直接体验屏幕共享</span></small
><span lang="zh-CN">打开浏览器就能开房间、发邀请</span></small
></span
>
</summary>
@@ -284,8 +279,8 @@
<ol class="guide-steps">
<li>
<strong
><span lang="en">Open the demo on a computer.</span
><span lang="zh-CN">在电脑上打开演示站。</span></strong
><span lang="en">Open Piik online on a computer.</span
><span lang="zh-CN">在电脑上打开在线版。</span></strong
>
<p>
<span lang="en"
@@ -313,36 +308,34 @@
</li>
<li>
<strong
><span lang="en">Open the invitation on a second device.</span
><span lang="zh-CN">用另一台设备打开邀请。</span></strong
><span lang="en">Send the invitation to your friends.</span
><span lang="zh-CN">把邀请发给朋友。</span></strong
>
<p>
<span lang="en"
>Use a phone or another computer, or invite a friend. Select <strong>Play</strong> if the browser
does not start playback.</span
>Friends can open the link on a phone or computer. Select <strong>Play</strong> if playback does not start automatically.</span
><span lang="zh-CN"
>使用手机、另一台电脑,或邀请朋友加入。如果没有自动播放,点击<strong>播放</strong>。</span
>朋友用手机或电脑打开链接即可观看。如果没有自动播放,点击<strong>播放</strong>。</span
>
</p>
</li>
<li>
<strong><span lang="en">Finish the demonstration.</span><span lang="zh-CN">结束体验。</span></strong>
<strong><span lang="en">End your share.</span><span lang="zh-CN">结束分享。</span></strong>
<p>
<span lang="en"
>Try the volume and fullscreen controls, then select <strong>Stop sharing</strong> on the host’s
page.</span
><span lang="zh-CN">试用音量、全屏等播放功能,完成后在房主页面点击<strong>停止分享</strong>。</span>
>When you are finished, select <strong>Stop sharing</strong> on the host page.</span
><span lang="zh-CN">结束时,在房主页面点击<strong>停止分享</strong>。</span>
</p>
</li>
</ol>
<aside class="guide-note">
<h3><span lang="en">About this demo</span><span lang="zh-CN">演示站说明</span></h3>
<h3><span lang="en">About this online site</span><span lang="zh-CN">在线版说明</span></h3>
<p id="demo-status">
<span lang="en"
>This demo uses direct device-to-device connections (P2P), with media forwarding disabled.
>This site uses direct device-to-device connections (P2P), with media forwarding disabled.
Restrictive networks may open the page but block the video.</span
><span lang="zh-CN"
>演示站仅使用设备间直连(P2P),未启用媒体转发。
>在线版仅使用设备间直连(P2P),未启用媒体转发。
在受限网络下,可能出现页面能打开、画面无法连接的情况。</span
>
</p>
@@ -351,7 +344,7 @@
><span lang="zh-CN">自行部署的 Piik 站点可以<a href="https://github.com/TNTcraftHIM/Piik/blob/main/docs/operations/self-hosting.zh-CN.md#可选启用媒体兜底">启用媒体转发(SFU)</a>,为直连失败提供兜底。</span>
</p>
<a class="button download-button" href="https://demo.piik.tv" aria-describedby="demo-status"
><span lang="en">Open the demo</span><span lang="zh-CN">进入演示站</span
><span lang="en">Use Piik online</span><span lang="zh-CN">直接在线使用</span
><span aria-hidden="true"> ↗</span></a
>
</aside>
@@ -483,8 +476,8 @@
macOS 原生采集需要 Apple 芯片和 macOS 13 及以上;Linux 原生采集需要<a href="https://github.com/TNTcraftHIM/Piik/blob/main/native/capture/linux/README.md">额外系统组件</a>。也可以用浏览器采集。</span
>
</p>
<a href="https://github.com/TNTcraftHIM/Piik/blob/main/docs/guide/getting-started.md" lang="en">Full App guide →</a>
<a href="https://github.com/TNTcraftHIM/Piik/blob/main/docs/guide/getting-started.zh-CN.md" lang="zh-CN">完整客户端使用指南 →</a>
<a href="https://github.com/TNTcraftHIM/Piik/blob/main/docs/guide/getting-started.md#share-with-piik-app" lang="en">Full App guide →</a>
<a href="https://github.com/TNTcraftHIM/Piik/blob/main/docs/guide/getting-started.zh-CN.md#用-piik-app-分享" lang="zh-CN">完整客户端使用指南 →</a>
</aside>
</div>
</details>
@@ -492,7 +485,7 @@
<summary>
<span class="guide-number">03</span
><span class="guide-heading"
><span lang="en">Browser &amp; self-hosting · Advanced</span><span lang="zh-CN">网页版与自建站点 · 进阶</span
><span lang="en">Share through an existing Piik site</span><span lang="zh-CN">通过已有 Piik 站点分享</span
><small
><span lang="en">Requires an existing site running Piik Server</span
><span lang="zh-CN">需要一个已部署 Piik Server 的站点</span></small
@@ -568,11 +561,11 @@
>
</p>
<p>
<span lang="en">Share from a desktop browser on an HTTPS site. Sources and audio options vary by browser and operating system. No site yet? Start with the <a href="#demo">demo</a> or <a href="#guide-app">App</a>.</span
><span lang="zh-CN">请用支持屏幕共享的电脑浏览器访问 HTTPS 站点。可分享的画面和声音取决于浏览器及系统。还没有站点时,可以先用<a href="#demo">在线 Demo</a> 或<a href="#guide-app">客户端</a>。</span>
<span lang="en">Share from a desktop browser on an HTTPS site. Sources and audio options vary by browser and operating system. No site yet? Start with the <a href="#demo">online site</a> or <a href="#guide-app">App</a>.</span
><span lang="zh-CN">请用支持屏幕共享的电脑浏览器访问 HTTPS 站点。可分享的画面和声音取决于浏览器及系统。还没有站点时,可以先用<a href="#demo">在线版</a> 或<a href="#guide-app">客户端</a>。</span>
</p>
<a href="https://github.com/TNTcraftHIM/Piik/blob/main/docs/guide/getting-started.md" lang="en">Full browser guide →</a>
<a href="https://github.com/TNTcraftHIM/Piik/blob/main/docs/guide/getting-started.zh-CN.md" lang="zh-CN">完整网页版使用指南 →</a>
<a href="https://github.com/TNTcraftHIM/Piik/blob/main/docs/guide/getting-started.md#share-from-your-browser" lang="en">Full browser guide →</a>
<a href="https://github.com/TNTcraftHIM/Piik/blob/main/docs/guide/getting-started.zh-CN.md#用浏览器分享" lang="zh-CN">完整网页版使用指南 →</a>
<a href="#docs"><span lang="en">Documentation &amp; deployment</span><span lang="zh-CN">文档中心与部署教程</span><span aria-hidden="true"> →</span></a>
</aside>
</div>
@@ -627,7 +620,7 @@
<summary><span lang="en">Do I need my own server?</span><span lang="zh-CN">需要自己搭服务器吗?</span></summary>
<p>
<span lang="en">No. Try the <a href="#demo">online demo</a>, or choose <strong>Public invite</strong> in Piik App to create a temporary room. You can also use a Piik site provided by someone else. For a permanent site under your own domain, follow the <a href="https://github.com/TNTcraftHIM/Piik/blob/main/docs/operations/self-hosting.md">self-hosting guide</a>.</span>
<span lang="zh-CN">不需要。可以直接体验<a href="#demo">在线 Demo</a>,或在 Piik App 中选择<strong>公网邀请</strong>创建临时房间,也可以使用别人提供的 Piik 站点。想用自己的域名长期运行一个站点,再看<a href="https://github.com/TNTcraftHIM/Piik/blob/main/docs/operations/self-hosting.zh-CN.md">部署指南</a>。</span>
<span lang="zh-CN">不需要。可以直接体验<a href="#demo">在线版</a>,或在 Piik App 中选择<strong>公网邀请</strong>创建临时房间,也可以使用别人提供的 Piik 站点。想用自己的域名长期运行一个站点,再看<a href="https://github.com/TNTcraftHIM/Piik/blob/main/docs/operations/self-hosting.zh-CN.md">部署指南</a>。</span>
</p>
</details>
<details>
@@ -680,14 +673,14 @@
</summary>
<p>
<span lang="en"
>Yes. Use the demo, an Internet-accessible Piik site, or Public invite in the App. Public invite gives
>Yes. Use Piik online, an Internet-accessible Piik site, or Public invite in the App. Public invite gives
your room a temporary address that can change or become unavailable; closing the App ends the room.
The demo and Public invite use direct media connections without server forwarding, so some networks
The online site and Public invite use direct media connections without server forwarding, so some networks
may load the page but block the video. You can also use a Piik site with media forwarding enabled
for a fallback connection.</span
><span lang="zh-CN"
>可以使用在线 Demo、能从外网访问的 Piik 站点,或客户端的“公网邀请”。公网邀请的地址是临时的,可能中断或变化,退出 App 后房间也会结束。
Demo 和公网邀请的画面靠设备间直连传输,没有服务器转发兜底;部分网络可能出现页面能打开、画面连不上的情况。
>可以使用在线版、能从外网访问的 Piik 站点,或客户端的“公网邀请”。公网邀请的地址是临时的,可能中断或变化,退出 App 后房间也会结束。
在线版和公网邀请的画面靠设备间直连传输,没有服务器转发兜底;部分网络可能出现页面能打开、画面连不上的情况。
也可以使用已启用媒体转发兜底的 Piik 站点。</span
>
</p>
@@ -738,12 +731,11 @@
</p>
<a
class="button download-button"
data-download="windows-amd64" data-provider="github"
href="https://github.com/TNTcraftHIM/Piik/releases/latest/download/piik-app-windows-amd64.zip"
aria-describedby="download-status"
><svg aria-hidden="true"><use href="#download-mark" /></svg><span lang="en">Download from GitHub</span
><span lang="zh-CN">GitHub 下载</span></a
><a data-download="windows-amd64" data-provider="gitee" href="https://gitee.com/TNTcraftHIM/Piik/releases/download/latest/piik-app-windows-amd64.zip"
><a href="https://gitee.com/TNTcraftHIM/Piik/releases/download/latest/piik-app-windows-amd64.zip"
><span lang="en">Download from Gitee</span><span lang="zh-CN">Gitee 国内镜像下载</span></a
>
</article>
@@ -768,12 +760,11 @@
</p>
<a
class="button download-button"
data-download="darwin-arm64" data-provider="github"
href="https://github.com/TNTcraftHIM/Piik/releases/latest/download/piik-app-darwin-arm64.zip"
aria-describedby="download-status"
><svg aria-hidden="true"><use href="#download-mark" /></svg><span lang="en">Download from GitHub</span
><span lang="zh-CN">GitHub 下载</span></a
><a data-download="darwin-arm64" data-provider="gitee" href="https://gitee.com/TNTcraftHIM/Piik/releases/download/latest/piik-app-darwin-arm64.zip"
><a href="https://gitee.com/TNTcraftHIM/Piik/releases/download/latest/piik-app-darwin-arm64.zip"
><span lang="en">Download from Gitee</span><span lang="zh-CN">Gitee 国内镜像下载</span></a
>
</article>
@@ -797,12 +788,11 @@
</p>
<a
class="button download-button"
data-download="linux-amd64" data-provider="github"
href="https://github.com/TNTcraftHIM/Piik/releases/latest/download/piik-app-linux-amd64.zip"
aria-describedby="download-status"
><svg aria-hidden="true"><use href="#download-mark" /></svg><span lang="en">Download from GitHub</span
><span lang="zh-CN">GitHub 下载</span></a
><a data-download="linux-amd64" data-provider="gitee" href="https://gitee.com/TNTcraftHIM/Piik/releases/download/latest/piik-app-linux-amd64.zip"
><a href="https://gitee.com/TNTcraftHIM/Piik/releases/download/latest/piik-app-linux-amd64.zip"
><span lang="en">Download from Gitee</span><span lang="zh-CN">Gitee 国内镜像下载</span></a
>
</article>
+3 -1
View File
@@ -20,7 +20,6 @@ function syncFilmPreferences() {
target.searchParams.set('lang', root.lang);
target.searchParams.set('theme', theme.value);
filmLink.href = target.href;
document.querySelector('[data-film-page]').href = target.href;
filmFrame.contentWindow?.postMessage({type:'piik-film-preferences',lang:root.lang,theme:theme.value}, location.origin);
}
const copy = {
@@ -47,6 +46,9 @@ function setLanguage(lang) {
document.title = current.title;
description.content = current.description;
roomIllustration.alt = current.image;
document.querySelector('.site-header nav').setAttribute('aria-label', chinese ? '主导航' : 'Main');
document.querySelector('.guide-links').setAttribute('aria-label', chinese ? '教程目录' : 'Guides');
filmFrame.title = chinese ? 'Piik 宣传片' : 'Piik introduction';
for (const option of theme.options) option.textContent = current.themes[option.value];
syncFilmPreferences();
}
-2
View File
@@ -77,7 +77,6 @@ h1 { font-size: clamp(49px, 5.7vw, 76px); line-height: 1.06; margin-block: 22px
.hero-note { color: var(--muted); font-size: 12px; line-height: 1.7; margin-top: 18px; }
.film-actions { display:flex; align-items:center; flex-wrap:wrap; gap:0 20px; margin-top:18px; }
.film-link { display:inline-flex; align-items:center; gap:10px; min-height:44px; text-decoration:none; font-size:12px; font-weight:650; }
.film-page-link { display:inline-flex; align-items:center; min-height:44px; color:var(--muted); font-size:12px; }
.film-play { display:grid; place-items:center; width:33px; height:33px; border-radius:50%; color:var(--orange); background:var(--paper); transition:transform .22s cubic-bezier(.2,.9,.3,1.4); }
.film-play svg { width:23px; height:23px; }
.film-link:is(:hover,:focus-visible) .film-play { transform:scale(1.12); }
@@ -115,7 +114,6 @@ h3 { font-size: 21px; font-weight: 700; letter-spacing: -.03em; line-height: 1.3
.steps li > p:last-child { margin-top: 12px; font-size: 14px; line-height: 1.75; color: var(--muted); }
.download-section { padding-block: 76px 60px; }
.release-note { display: flex; justify-content: center; align-items: baseline; flex-wrap: wrap; gap: 8px 13px; margin: 27px auto; color: var(--muted); font-size: 13px; text-align: center; }
.release-label { display: inline-block; padding: 3px 10px; background: var(--soft-orange); border-radius: 8px; color: var(--ink); font-weight: 650; white-space: nowrap; }
.download-cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; }
.download-card { display: flex; flex-direction: column; align-items: flex-start; padding: 28px; background: var(--paper); border: 1px solid var(--line); border-radius: 24px; box-shadow: 0 4px 0 color-mix(in srgb, var(--line) 40%, transparent); }
.platform-mark { display: grid; place-items: center; width: 52px; height: 52px; margin-bottom: 18px; border-radius: 16px; background: var(--wall); }
+17 -25
View File
@@ -21,18 +21,14 @@ import {
takeClientLaunchBootstrap,
} from "./lib/session";
import { AppHeader } from "./components/living/Header";
import { BrandLoader } from "./components/living/BrandMark";
import { Btn, Pill } from "./components/living/primitives";
import { Comic, type ComicKind } from "./components/living/Comic";
import { Glyph } from "./ui/icons";
import { setCopy, useCopy } from "./ui/copy";
import { applyLaunchCopy, useCopy } from "./ui/copy";
import { consoleLanguage } from "./locales";
import { initTheme } from "./ui/theme";
import { installBrowserDebug, withBrowserDebug } from "./lib/debug";
const OverlayPreviewPage = import.meta.env.DEV
? lazy(() => import("./pages/OverlayPreviewPage").then((module) => ({ default: module.OverlayPreviewPage })))
: null;
const TooltipPreviewPage = import.meta.env.DEV
? lazy(() => import("./pages/TooltipPreviewPage").then((module) => ({ default: module.TooltipPreviewPage })))
: null;
@@ -49,9 +45,11 @@ const clientLaunchBootstrap =
appRoute.kind === "host" || appRoute.kind === "viewer"
? takeClientLaunchBootstrap()
: null;
initTheme(clientLaunchBootstrap?.presentation?.theme);
initTheme(clientLaunchBootstrap?.presentation?.theme,
clientLaunchBootstrap?.presentation?.explicit?.includes("theme") ?? false);
if (clientLaunchBootstrap?.presentation) {
setCopy(clientLaunchBootstrap.presentation);
applyLaunchCopy(clientLaunchBootstrap.presentation,
clientLaunchBootstrap.presentation.explicit?.includes("copy") ?? false);
}
const clientAccessBootstrap = clientLaunchBootstrap?.accessToken ?? null;
const viewerRoute = appRoute.kind === "viewer" ? readViewerRoute() : null;
@@ -150,9 +148,6 @@ function AppRoute() {
if (StatusPreviewPage && window.location.pathname === "/__status-preview") {
return <StatusPreviewPage />;
}
if (OverlayPreviewPage && window.location.pathname === "/__overlay-preview") {
return <OverlayPreviewPage />;
}
if (TooltipPreviewPage && window.location.pathname === "/__tooltip-preview") {
return <TooltipPreviewPage />;
}
@@ -177,7 +172,7 @@ function AppRoute() {
}
return appRoute.kind === "malformed-room" ? (
<StaticRoute
comic="room-not-found"
comic="room-code-invalid"
titleKey="gate.malformed"
hintKey="gate.malformedHint"
action="join"
@@ -196,15 +191,15 @@ function RouteLoader() {
<div
className="lr-loading"
role="status"
aria-label={t("gate.checking")}
aria-label={t("common.loading")}
>
<BrandLoader />
<Comic kind="signal-connecting" theme="paper" />
{vis ? null : (
<span
className="lr-tv-msg"
style={{ color: "var(--ink)", textShadow: "none" }}
>
{t("gate.checking")}
{t("common.loading")}
</span>
)}
</div>
@@ -230,7 +225,7 @@ function StaticRoute({
<AppHeader />
<main className="lr-join">
<div className="lr-join-panel">
<Comic kind={comic} theme="paper" />
<Comic kind={comic} theme="paper" tone="bad" />
{vis ? null : (
<div className="lr-access-text">
<h1>{t(titleKey)}</h1>
@@ -253,6 +248,7 @@ function StaticRoute({
icon="refresh"
title="common.refresh"
cap="common.refresh"
hint="page-refresh"
onClick={() => window.location.reload()}
/>
) : null}
@@ -413,7 +409,7 @@ function SiteAccessGate({
role="status"
aria-label={t("gate.checking")}
>
<BrandLoader />
<Comic kind="signal-connecting" theme="paper" />
{vis ? null : (
<span className="lr-tv-msg" style={{ color: "var(--ink)", textShadow: "none" }}>
{t("gate.checking")}
@@ -422,9 +418,7 @@ function SiteAccessGate({
</div>
) : access.kind === "unavailable" ? (
<div className="lr-join-panel">
<span className="lr-tv-big" style={{ borderColor: "var(--ink)", color: "var(--ink)", background: "var(--paper)" }}>
<Glyph name="wifiOff" size={30} />
</span>
<Comic kind="signal-failed" theme="paper" />
{vis ? (
<span className="visually-hidden" role="alert">{access.message}</span>
) : (
@@ -433,13 +427,11 @@ function SiteAccessGate({
<p role="alert">{access.message}</p>
</div>
)}
<Btn icon="refresh" title="common.retry" cap="common.retry" onClick={() => void retry()} />
<Btn icon="refresh" title="common.retry" cap="common.retry" hint="signal-connecting" hintTone="off" hintMotion="demo" onClick={() => void retry()} />
</div>
) : (
<form className="lr-join-panel" onSubmit={(event) => void submit(event)}>
<span className="lr-tv-big" style={{ borderColor: "var(--ink)", color: "var(--ink)", background: "var(--paper)" }}>
<Glyph name="key" size={30} draw="gate-key" />
</span>
<Comic kind="site-access" theme="paper" />
{vis ? null : (
<div className="lr-access-text">
<h1>{t("gate.title")}</h1>
@@ -447,7 +439,7 @@ function SiteAccessGate({
</div>
)}
<span className="lr-input" style={{ minWidth: 240 }}>
<Glyph name="lock" size={17} />
<Glyph name="key" size={17} />
<input
type="password"
value={password}
@@ -460,7 +452,7 @@ function SiteAccessGate({
/>
</span>
{access.error ? (
<Pill icon="alert" tone="bad" label={access.error} alert comic="warning" />
<Pill icon="alert" tone="bad" label={access.error} alert comic="access-denied" />
) : null}
<Btn
icon="arrowRight"
@@ -3,17 +3,18 @@ import { useCallback, useEffect, useId, useRef, useState } from "react";
import type { NativeCaptureTarget } from "../../native/wire";
import { nativeCaptureTargetKey } from "../../native/capture-selection";
import { useCopy } from "../../ui/copy";
import { Glyph } from "../../ui/icons";
import { Glyph, type GlyphName } from "../../ui/icons";
import { Tooltip } from "./Tooltip";
import { HintComic } from "./hints";
import { Pill } from "./primitives";
const SOURCE_TABS = ["browser", "window", "display"] as const;
type SourceTab = (typeof SOURCE_TABS)[number];
const SOURCE_ICONS = {
browser: "globe",
window: "switchSource",
display: "tv",
};
window: "window",
display: "display",
} satisfies Record<SourceTab, GlyphName>;
export type NativeSourceList =
| { kind: "loading" }
@@ -54,13 +55,17 @@ export function CaptureSourcePicker({
}) {
const { vis, t } = useCopy();
const pickerId = useId();
const panelRef = useRef<HTMLDivElement>(null);
const [tab, setTab] = useState<SourceTab>(initialTab);
const [shareAudio, setShareAudio] = useState(initialAudio);
const activeTab = tab === "browser" && !browserAvailable ? "window" : tab;
useEffect(() => {
const cancelOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") onCancel();
if (event.key !== "Escape" || event.defaultPrevented ||
!panelRef.current?.contains(event.target as Node | null)) return;
event.preventDefault();
onCancel();
};
window.addEventListener("keydown", cancelOnEscape);
return () => window.removeEventListener("keydown", cancelOnEscape);
@@ -123,9 +128,9 @@ export function CaptureSourcePicker({
return (
<div
ref={panelRef}
className={`lr-tv-overlay lr-source-picker${vis ? " is-visual" : ""}`}
role="dialog"
aria-modal="true"
aria-label={t("host.sourcePicker.title")}
>
<div className="lr-source-picker-panel">
@@ -138,11 +143,12 @@ export function CaptureSourcePicker({
<strong>{t("host.sourcePicker.title")}</strong>
</span>
)}
{vis ? refreshButton : (
<Tooltip text={refreshLabel} place="below" align="end">
{refreshButton}
</Tooltip>
)}
<Tooltip kind="hint-refresh-sources" text={vis ? undefined : refreshLabel}
tone={nativeSources.kind === "loading" ? "busy" : undefined}
motion={nativeSources.kind === "loading" ? "progress" : undefined}
place="below" align="end">
{refreshButton}
</Tooltip>
<Tooltip kind="hint-close" text={vis ? undefined : t("common.cancel")} place="below" align="end">
{closeButton}
</Tooltip>
@@ -289,7 +295,7 @@ export function CaptureSourcePicker({
role="status"
aria-label={t("host.sourcePicker.loading")}
>
<Glyph name="loader" size={18} className="lr-spin" />
<HintComic kind="hint-refresh-sources" size={240} tone="busy" motion="progress" />
{vis ? null : <span>{t("host.sourcePicker.loading")}</span>}
</span>
) : sources.length === 0 ? (
@@ -298,7 +304,7 @@ export function CaptureSourcePicker({
role="status"
aria-label={t("host.sourcePicker.empty")}
>
<Glyph name="eyeOff" size={22} />
<HintComic kind="hint-no-sources" size={240} />
{vis ? null : t("host.sourcePicker.empty")}
</span>
) : null}
@@ -369,7 +375,7 @@ function CaptureSourceOption({
);
return (
<Tooltip text={title} className="lr-source-option-hint">
<Tooltip kind={target.kind === "picker" ? "hint-source-picker" : target.kind === "display" ? "hint-capture-display" : "hint-capture-window"} text={title} className="lr-source-option-hint">
<button
ref={buttonRef}
type="button"
@@ -388,7 +394,7 @@ function CaptureSourceOption({
{preview ? (
<img src={preview} alt="" />
) : (
<Glyph name={target.kind === "window" ? "share" : "tv"} size={23} />
<Glyph name={target.kind === "picker" ? "share" : target.kind} size={23} />
)}
</span>
</button>
+315 -104
View File
@@ -6,6 +6,7 @@
import { memo, type CSSProperties, type ReactNode } from "react";
import { HostMark } from "./HostMark";
import { Glyph } from "../../ui/icons";
import { PersonShape } from "./Pawn";
import { comicStyle, getComicPresentation, type ComicMotion, type ComicTone } from "./comic-presentation";
import type { ComicKind, ComicTheme } from "../../ui/visual-kinds";
@@ -19,6 +20,27 @@ const DEFAULT_THEME: Record<ComicKind, ComicTheme> = {
"signal-connecting": "paper",
"signal-recovering": "paper",
"signal-offline": "paper",
"signal-connected": "paper",
"signal-failed": "paper",
"media-playing": "stage",
"media-ready": "paper",
"share-live": "stage",
"share-ended": "stage",
"room-closed": "paper",
"room-code-invalid": "paper",
"page-refresh": "paper",
"site-access": "paper",
"source-switching": "stage",
"source-starting": "stage",
"preview-paused": "stage",
"update-available": "paper",
"debug-start": "paper",
"debug-export-failed": "paper",
"copy-failed": "paper",
"source-failed": "stage",
"settings-failed": "paper",
"name-invalid": "paper",
"transport-connected": "paper",
"tap-to-play": "stage",
"host-paused": "stage",
recovering: "stage",
@@ -41,7 +63,7 @@ const DEFAULT_THEME: Record<ComicKind, ComicTheme> = {
* ------------------------------------------------------------------ */
/* Control hints reuse these scene objects and stable identity colours. */
export const YOU = "#2fa66a";
export const YOU = "var(--you)";
export const LIVE = "var(--live)";
export const WARN = "var(--warn)";
export const DANGER = "var(--danger)";
@@ -120,7 +142,8 @@ export function Pawn({
className?: string;
}) {
const scale = s / 16;
const inner = <g transform={`translate(${r2(x - 24 * scale)} ${r2(yb - 50 * scale)}) scale(${scale})`} fill={color}>
const inner = <g transform={`translate(${r2(x - 24 * scale)} ${r2(yb - 50 * scale)}) scale(${scale})`} fill={color}
style={{ "--comic-blink-delay": `${Math.round(x * .8)}ms` } as CSSProperties}>
<PersonShape eyes={eyes} gaze={gaze} eyeClassName={eyeClassName} host={host} />
</g>;
return className ? <g className={className}>{inner}</g> : inner;
@@ -179,6 +202,17 @@ export function BrowserWindow({ x, y, w, h, children }: {
</>;
}
/** Invitation URL: the same 24-unit chain as the link control icon. */
export function InviteLink({ x, y, size = 24, className }: {
x: number; y: number; size?: number; className?: string;
}) {
return <g className={className}>
<g transform={`translate(${x} ${y})`} color="var(--ink)">
<Glyph name="link" size={size} />
</g>
</g>;
}
/** Crescent moon (host not live / room asleep). Static; safe to transform. */
export function Moon({
x,
@@ -380,13 +414,14 @@ export function Static({
);
}
/** Settled and reduced-motion scenes share the same informative poses. */
export function rmBlock(kills: string[], pins: Array<readonly [string, string]>): string {
/** Hold operation results in settled scenes; decorative subject motion may opt
* out. System and preview reduced motion always use the explicit final pose. */
export function rmBlock(kills: string[], pins: Array<readonly [string, string]>, holdOnStill = true): string {
const rules = [...kills.map((c) => [`.${c}`, "animation:none"] as const), ...pins];
const block = (scope: string) => rules.map(([selectors, styles]) =>
`${selectors.split(",").map((selector) => scope + selector.trim()).join(",")}{${styles}}`,
).join("");
return `${block('svg[data-comic-motion="still"] ')}@media (prefers-reduced-motion:reduce){${block("")}}`;
return `${holdOnStill ? block('svg[data-comic-motion="still"] ') : ""}${block('[data-comic-reduced-motion] ')}@media (prefers-reduced-motion:reduce){${block("")}}`;
}
/* ------------------------------ scenes ------------------------------ */
@@ -396,34 +431,29 @@ function SceneWaiting({ theme }: { theme: ComicTheme }) {
return (
<>
<style>{`
.vls-wf-moon{transform-box:fill-box;transform-origin:center;animation:vlsWfMoon var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-wf-z1{animation:vlsWfZ var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-wf-z2{animation:vlsWfZ var(--comic-duration,3.2s) ease-in-out .45s var(--comic-repeat,1) both}
.vls-wf-z3{animation:vlsWfZ var(--comic-duration,3.2s) ease-in-out .9s var(--comic-repeat,1) both}
.vls-wf-eyes{transform-box:fill-box;transform-origin:center;animation:vlsWfBlink var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-wf-led{animation:vlsWfLed var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
@keyframes vlsWfMoon{0%,100%{transform:translateY(0)}50%{transform:translateY(-2px)}}
@keyframes vlsWfZ{0%{opacity:0;transform:translateY(3px)}8%{opacity:.9}20%,100%{opacity:0;transform:translateY(-3px)}}
@keyframes vlsWfBlink{0%,66%,74%,100%{transform:scaleY(1)}70%{transform:scaleY(.12)}}
@keyframes vlsWfLed{0%,100%{opacity:1}50%{opacity:.35}}
.vls-wf-z1{animation:vlsWfZNear var(--comic-duration,3.2s) ease-out 1 both}
.vls-wf-z2{animation:vlsWfZFar var(--comic-duration,3.2s) ease-out 1 both}
@keyframes vlsWfZNear{0%{opacity:.85;transform:translate(-5px,10px)}42%,100%{opacity:.65;transform:none}}
@keyframes vlsWfZFar{0%,6%{opacity:.75;transform:translate(-12px,14px)}48%,100%{opacity:.55;transform:none}}
${rmBlock(
["vls-wf-moon", "vls-wf-z1", "vls-wf-z2", "vls-wf-z3", "vls-wf-eyes", "vls-wf-led"],
[[".vls-wf-z1,.vls-wf-z2,.vls-wf-z3", "opacity:.55"], [".vls-wf-led", "opacity:1"]],
["vls-wf-z1", "vls-wf-z2"],
[[".vls-wf-z1", "opacity:.65;transform:none"], [".vls-wf-z2", "opacity:.55;transform:none"]],
false,
)}
`}</style>
<Frame x={4} w={312} theme={theme} result />
<Floor x1={24} x2={296} />
<Pawn x={69} yb={76} s={11} eyes gaze={2} eyeClassName="vls-wf-eyes" />
<Pawn x={69} yb={76} s={11} eyes gaze={2} />
<MiniTv x={206} y={28} w={64} h={42} />
<g className="vls-wf-moon">
<Moon x={229} y={48} />
<Moon x={229} y={48} />
<g stroke={LINE} strokeWidth={2.5} strokeLinecap="round" fill="none">
<path className="vls-wf-z1" opacity={0.65} d="M246 40h8l-8 6h8" />
<g className="vls-wf-z2" opacity={0.55}>
<path d="M258 28h9l-9 7h9" />
<path d="M273 14h10l-10 8h10" />
</g>
</g>
<g stroke="#a9bcd4" strokeWidth={2} strokeLinecap="round" fill="none">
<path className="vls-wf-z1" opacity={0.55} d="M244 51h6l-6 4h6" />
<path className="vls-wf-z2" opacity={0.55} d="M251 44h7l-7 5h7" />
<path className="vls-wf-z3" opacity={0.55} d="M258 36h8l-8 6h8" />
</g>
<circle className="vls-wf-led" cx={238} cy={76} r={3.5} fill={WARN} />
<circle cx={238} cy={76} r={3.5} fill={WARN} />
</>
);
}
@@ -505,29 +535,33 @@ ${rmBlock(kills, pins)}
}
/** The page's control link, not a media path: no TV or video packets here. */
function SceneSignal({ theme, state }: {
function SceneSignal({ theme, state, peer = false }: {
theme: ComicTheme;
state: "connecting" | "recovering" | "offline";
state: "connecting" | "recovering" | "offline" | "connected" | "failed";
peer?: boolean;
}) {
const color = state === "connecting" ? SKY : state === "recovering" ? WARN : FAINT;
const color = "var(--comic-tone, var(--ink))";
return (
<>
<style>{`
.vls-signal-message{animation:vlsSignalMessage var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-signal-retry{transform-box:fill-box;transform-origin:center;animation:vlsSignalRetry var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-signal-result{transform-box:fill-box;transform-origin:center;animation:vlsSignalResult var(--comic-duration,3.2s) ease-out 1 both}
@keyframes vlsSignalMessage{0%{transform:translateX(0);opacity:0}8%{opacity:1}40%{transform:translateX(82px);opacity:1}55%,100%{transform:translateX(82px);opacity:0}}
@keyframes vlsSignalRetry{0%{transform:rotate(0)}55%,100%{transform:rotate(360deg)}}
@keyframes vlsSignalResult{0%,8%,42%,100%{transform:none}20%{transform:scale(.9)}30%{transform:scale(1.04)}}
${rmBlock(["vls-signal-message", "vls-signal-retry"], [
[".vls-signal-message", "opacity:1;transform:translateX(41px)"],
[".vls-signal-retry", "transform:none"],
])}
${rmBlock(["vls-signal-result"], [[".vls-signal-result", "transform:none"]], false)}
`}</style>
<Frame x={4} w={312} theme={theme} result />
<BrowserWindow x={24} y={23} w={96} h={50}>
<circle cx={37} cy={43} r={3} fill={color} />
<path d="M47 43h56 M34 55h36 M77 55h26 M34 64h69" stroke={LINE} strokeWidth={2} strokeLinecap="round" />
</BrowserWindow>
<ServerBox x={254} y={25} w={42} h={46} />
{peer ? <BrowserWindow x={251} y={25} w={47} h={46} /> : <ServerBox x={254} y={25} w={42} h={46} />}
<path
d={state === "connecting" ? "M130 48H244" : "M130 48H164 M204 48H244"}
stroke={color}
@@ -548,8 +582,12 @@ ${rmBlock(["vls-signal-message", "vls-signal-retry"], [
<g className="vls-signal-retry" stroke={color} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none">
<path d="M175 43a11 11 0 0 1 19-2l2 3 M190 44h6v-6 M193 53a11 11 0 0 1-19 2l-2-3 M178 52h-6v6" />
</g>
) : state === "connected" ? (
<path className="vls-signal-result" d="M174 48l7 7 14-17" stroke={color} strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" fill="none" />
) : state === "failed" ? (
<RedX cx={184} cy={48} arm={8} className="vls-signal-result" />
) : (
<g stroke={color} strokeWidth={2.5} strokeLinecap="round" fill="none">
<g className="vls-signal-result" stroke={color} strokeWidth={2.5} strokeLinecap="round" fill="none">
<path d="M174 43h5 M174 53h5 M189 43h5 M189 53h5 M176 59l16-22" />
</g>
)}
@@ -621,15 +659,15 @@ function ScenePaused({ theme }: { theme: ComicTheme }) {
@keyframes vlsHpBars{0%,100%{opacity:.55}50%{opacity:1}}
@keyframes vlsHpLed{0%,100%{opacity:1}50%{opacity:.3}}
@keyframes vlsHpBlink{0%,44%,52%,100%{transform:scaleY(1)}48%{transform:scaleY(.12)}}
@keyframes vlsHpSteam{0%{transform:translateY(2px);opacity:0}25%{opacity:.9}55%,100%{transform:translateY(-5px);opacity:0}}
@keyframes vlsHpSteam{0%{transform:translateY(2px);opacity:.4}20%{opacity:.9}45%,100%{transform:translateY(-2px);opacity:.55}}
${rmBlock(
["vls-hp-bars", "vls-hp-led", "vls-hp-eyes", "vls-hp-steam1", "vls-hp-steam2"],
["vls-hp-bars", "vls-hp-led", "vls-hp-eyes", "vls-hp-steam2"],
[
[".vls-hp-bars,.vls-hp-led", "opacity:1"],
[".vls-hp-steam1", "opacity:.55;transform:translateY(-2px)"],
[".vls-hp-steam2", "opacity:0"],
],
)}
${rmBlock(["vls-hp-steam1"], [[".vls-hp-steam1", "opacity:.55;transform:translateY(-2px)"]], false)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} result />
@@ -641,7 +679,7 @@ ${rmBlock(
</g>
<circle className="vls-hp-led" cx={80} cy={76} r={3.5} fill={WARN} />
<Floor x1={176} x2={304} y={78} />
<Pawn x={216} yb={78} s={14} eyes eyeClassName="vls-hp-eyes" />
<Pawn x={216} yb={78} s={14} eyes host eyeClassName="vls-hp-eyes" />
<rect x={240} y={66} width={12} height={12} rx={3} fill="#e4572e" />
<path d="M252 69 a5 5 0 0 1 0 6" stroke="#e4572e" strokeWidth={2.5} fill="none" />
<path className="vls-hp-steam1" d="M243 60 q3 -4 0 -8" stroke={LINE} strokeWidth={2} strokeLinecap="round" fill="none" opacity={0} />
@@ -717,18 +755,18 @@ function SceneRouteFailed({ theme }: { theme: ComicTheme }) {
.vls-rf-p2{animation-delay:.2s}
.vls-rf-march{animation:vlsRfMarch var(--comic-duration,3.2s) linear .4s var(--comic-repeat,1) both}
.vls-rf-x{transform-box:fill-box;transform-origin:center;animation:vlsRfX var(--comic-duration,3.2s) ease-out .8s var(--comic-repeat,1) both}
.vls-rf-sweat{animation:vlsRfSweat var(--comic-duration,3.2s) ease-in-out 1.2s var(--comic-repeat,1) both}
.vls-rf-sweat{animation:vlsRfSweat var(--comic-duration,3.2s) ease-in-out 1 both}
@keyframes vlsRfIn{from{opacity:0;transform:translateY(6px) scale(.9)}}
@keyframes vlsRfMarch{from{stroke-dashoffset:0}to{stroke-dashoffset:-16}}
@keyframes vlsRfX{from{opacity:0;transform:scale(1.5)}to{opacity:1;transform:scale(1)}}
@keyframes vlsRfSweat{0%,65%,100%{opacity:.35;transform:translateY(0)}30%{opacity:1;transform:translateY(3px)}}
@keyframes vlsRfSweat{0%,50%,100%{opacity:.7;transform:translateY(0)}24%{opacity:1;transform:translateY(3px)}}
${rmBlock(
["vls-rf-panel", "vls-rf-march", "vls-rf-x", "vls-rf-sweat"],
["vls-rf-panel", "vls-rf-march", "vls-rf-x"],
[
[".vls-rf-x", "opacity:1;transform:none"],
[".vls-rf-sweat", "opacity:.7;transform:none"],
],
)}
${rmBlock(["vls-rf-sweat"], [[".vls-rf-sweat", "opacity:.7;transform:none"]], false)}
`}</style>
<g className="vls-rf-panel">
<Frame x={4} w={152} theme={theme} />
@@ -768,12 +806,13 @@ function ScenePlaybackFailed({ theme }: { theme: ComicTheme }) {
@keyframes vlsPfTap{0%,16%{transform:translate(0,0)}20%{transform:translate(4px,0)}24%{transform:translate(0,0)}31%{transform:translate(0,0)}35%{transform:translate(4px,0)}39%,100%{transform:translate(0,0)}}
@keyframes vlsPfRing{0%,55%{opacity:.25}63%{opacity:1}78%,100%{opacity:.25}}
${rmBlock(
["vls-pf-s1", "vls-pf-s2", "vls-pf-rip", "vls-pf-pawn", "vls-pf-ring"],
["vls-pf-s2", "vls-pf-rip", "vls-pf-pawn", "vls-pf-ring"],
[
[".vls-pf-s1,.vls-pf-s2,.vls-pf-rip", "opacity:.35;transform:none"],
[".vls-pf-s2,.vls-pf-rip", "opacity:.35;transform:none"],
[".vls-pf-ring", "opacity:1"],
],
)}
${rmBlock(["vls-pf-s1"], [[".vls-pf-s1", "opacity:.5;transform:none"]], false)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} result />
@@ -812,20 +851,21 @@ function SceneHostOffline({ theme }: { theme: ComicTheme }) {
.vls-ho-plug{transform-box:fill-box;transform-origin:50% 0%;animation:vlsHoSway var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-ho-pawn{transform-box:fill-box;transform-origin:50% 100%;animation:vlsHoWave var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-ho-badge{animation:vlsHoBadge var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
@keyframes vlsHoSway{0%{transform:rotate(-6deg)}10%{transform:rotate(6deg)}20%{transform:rotate(-5deg)}30%{transform:rotate(4deg)}40%,100%{transform:rotate(0)}}
@keyframes vlsHoSway{0%{transform:rotate(-3deg)}14%{transform:rotate(3deg)}28%{transform:rotate(-2deg)}42%,100%{transform:rotate(0)}}
@keyframes vlsHoWave{0%,10%{transform:rotate(0)}14%{transform:rotate(-8deg)}18%{transform:rotate(8deg)}22%{transform:rotate(-8deg)}26%{transform:rotate(8deg)}30%,100%{transform:rotate(0)}}
@keyframes vlsHoBadge{0%,30%{opacity:.2}40%{opacity:.7}50%,100%{opacity:.2}}
${rmBlock(
["vls-ho-plug", "vls-ho-pawn", "vls-ho-badge"],
[[".vls-ho-plug", "transform:none"], [".vls-ho-badge", "opacity:.5"]],
["vls-ho-pawn", "vls-ho-badge"],
[[".vls-ho-badge", "opacity:.5"]],
)}
${rmBlock(["vls-ho-plug"], [[".vls-ho-plug", "transform:none"]], false)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} result />
<MiniTv x={30} y={22} w={70} h={44} />
<rect x={36} y={28} width={58} height={30} rx={4} fill="#0a101c" />
<path d="M65 66 c0 6 -4 8 -8 10" stroke={TV_EDGE} strokeWidth={2.5} fill="none" strokeLinecap="round" />
<Plug x={57} y={76} className="vls-ho-plug" />
<path d="M65 66 c0 4 -4 6 -8 7" stroke={TV_EDGE} strokeWidth={2.5} fill="none" strokeLinecap="round" />
<Plug x={57} y={73} className="vls-ho-plug" />
<Floor x1={176} x2={304} y={78} />
<Pawn x={210} yb={78} s={9} eyes className="vls-ho-pawn" />
<HostMark x={257} y={43} width={30} dashed className="vls-ho-badge" opacity={0.5} />
@@ -853,16 +893,17 @@ function SceneNoAudio({ theme }: { theme: ComicTheme }) {
@keyframes vlsNaArc{0%{opacity:.45}12%{opacity:1}26%{opacity:.45}42%,100%{opacity:.8}}
@keyframes vlsNaArm{0%,8%{opacity:0;transform:translateY(10px)}20%,100%{opacity:1;transform:translateY(0)}}
@keyframes vlsNaSlash{0%,22%{opacity:0;transform:scale(1.7)}30%,100%{opacity:1;transform:scale(1)}}
@keyframes vlsNaShake{0%,36%,56%,100%{transform:rotate(0)}40%{transform:rotate(-2.5deg)}44%{transform:rotate(2.5deg)}48%{transform:rotate(-1.8deg)}52%{transform:rotate(1.8deg)}}
@keyframes vlsNaShake{0%,10%,42%,100%{transform:rotate(0)}16%{transform:rotate(-2.5deg)}22%{transform:rotate(2.5deg)}28%{transform:rotate(-1.8deg)}34%{transform:rotate(1.8deg)}}
${rmBlock(
["vls-na-flick", "vls-na-a1", "vls-na-a2", "vls-na-a3", "vls-na-pawn", "vls-na-arm", "vls-na-slash"],
["vls-na-flick", "vls-na-a1", "vls-na-a2", "vls-na-a3", "vls-na-arm", "vls-na-slash"],
[
[".vls-na-flick", "opacity:.5"],
[".vls-na-a1,.vls-na-a2,.vls-na-a3", "opacity:.8"],
[".vls-na-slash", "opacity:1;transform:none"],
[".vls-na-arm,.vls-na-pawn", "transform:none"],
[".vls-na-arm", "transform:none"],
],
)}
${rmBlock(["vls-na-pawn"], [[".vls-na-pawn", "transform:none"]], false)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} result />
@@ -880,7 +921,7 @@ ${rmBlock(
<path className="vls-na-a3" d="M118 26a22 22 0 0 1 0 36" />
</g>
{/* panel 2 (after): picture still alive on the mini TV, but the sound
arcs arrive dashed and carry a stamped red slash; the pawn raises
arcs arrive dashed and carry an unavailable mark; the pawn raises
two floating round hands toward its ears. */}
<MiniTv x={172} y={30} w={42} h={30} />
<path d="M187 37.5 L199 43 L187 48.5 Z" fill={MINT} />
@@ -900,7 +941,7 @@ ${rmBlock(
<path
className="vls-na-slash"
d="M216 29 L250 63"
stroke={DANGER}
stroke="var(--comic-tone)"
strokeWidth={3.5}
strokeLinecap="round"
fill="none"
@@ -934,9 +975,10 @@ function SceneNotFound({ theme }: { theme: ComicTheme }) {
@keyframes vlsRnGhost{0%,30%{opacity:0}45%,80%{opacity:.9}92%,100%{opacity:0}}
@keyframes vlsRnPuff{0%,38%{opacity:0;transform:translateY(3px)}50%{opacity:.6}68%,100%{opacity:0;transform:translateY(-4px)}}
${rmBlock(
["vls-rn-knock", "vls-rn-kn", "vls-rn-solid", "vls-rn-ghost", "vls-rn-puff"],
["vls-rn-kn", "vls-rn-solid", "vls-rn-ghost", "vls-rn-puff"],
[[".vls-rn-solid", "opacity:0"], [".vls-rn-ghost", "opacity:.9"], [".vls-rn-puff,.vls-rn-kn", "opacity:0"]],
)}
${rmBlock(["vls-rn-knock"], [[".vls-rn-knock", "transform:none"]], false)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} result />
@@ -981,7 +1023,8 @@ function SceneAccessDenied({ theme }: { theme: ComicTheme }) {
.vls-ad-x{transform-box:fill-box;transform-origin:center;animation:vlsAdX var(--comic-duration,3.2s) ease-out var(--comic-repeat,1) both}
@keyframes vlsAdShake{0%{transform:translateX(0)}3%{transform:translateX(-3px)}6%{transform:translateX(3px)}9%{transform:translateX(-2px)}12%{transform:translateX(2px)}15%,100%{transform:translateX(0)}}
@keyframes vlsAdX{0%,18%{opacity:0;transform:scale(1.6) rotate(8deg)}24%,100%{opacity:1;transform:scale(1) rotate(8deg)}}
${rmBlock(["vls-ad-door", "vls-ad-x"], [[".vls-ad-x", "opacity:1;transform:scale(1) rotate(8deg)"]])}
${rmBlock(["vls-ad-x"], [[".vls-ad-x", "opacity:1;transform:scale(1) rotate(8deg)"]])}
${rmBlock(["vls-ad-door"], [[".vls-ad-door", "transform:none"]], false)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} result />
@@ -1004,51 +1047,23 @@ ${rmBlock(["vls-ad-door", "vls-ad-x"], [[".vls-ad-x", "opacity:1;transform:scale
);
}
/** 13. invalid-invite: 2 paper panels. The ticket is broken. */
/** 13. invalid-invite: this invitation URL cannot admit the viewer. */
function SceneInvalidInvite({ theme }: { theme: ComicTheme }) {
return (
<>
<style>{`
.vls-ii-l{transform-box:fill-box;transform-origin:center;animation:vlsIiL var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-ii-r{transform-box:fill-box;transform-origin:center;animation:vlsIiR var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-ii-x{transform-box:fill-box;transform-origin:center;animation:vlsIiX var(--comic-duration,3.2s) ease-out var(--comic-repeat,1) both}
@keyframes vlsIiL{0%{transform:translate(0,0) rotate(0)}35%,100%{transform:translate(-3px,1px) rotate(-5deg)}}
@keyframes vlsIiR{0%{transform:translate(0,0) rotate(0)}35%,100%{transform:translate(3px,-1px) rotate(5deg)}}
@keyframes vlsIiX{0%,38%{opacity:0;transform:scale(1.6) rotate(8deg)}45%,100%{opacity:1;transform:scale(1) rotate(8deg)}}
${rmBlock(
["vls-ii-l", "vls-ii-r", "vls-ii-x"],
[
[".vls-ii-l", "transform:translate(-3px,1px) rotate(-5deg)"],
[".vls-ii-r", "transform:translate(3px,-1px) rotate(5deg)"],
[".vls-ii-x", "opacity:1;transform:scale(1) rotate(8deg)"],
],
)}
.vls-ii-link{animation:vlsIiLink var(--comic-duration,3.2s) ease-in-out 1 both}
@keyframes vlsIiLink{0%,8%,36%,100%{transform:none}17%{transform:translateX(-4px)}26%{transform:translateX(3px)}}
${rmBlock(["vls-ii-link"], [[".vls-ii-link", "transform:none"]], false)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} result />
<Pawn x={48} yb={76} s={9} eyes />
<rect x={62} y={40} width={20} height={13} rx={2} fill="var(--paper)" stroke="var(--ink)" strokeWidth={2} />
<path d="M62 42 L72 48 L82 42" stroke="var(--ink)" strokeWidth={1.5} fill="none" />
<Pawn x={186} yb={76} s={9} eyes />
<g className="vls-ii-l">
<path
d="M228 38 H241 L238 42 L242 46 L238 50 L241 54 H228 Z"
fill="var(--paper)"
stroke="var(--ink)"
strokeWidth={2}
strokeLinejoin="round"
/>
</g>
<g className="vls-ii-r">
<path
d="M252 38 H241 L244 42 L240 46 L244 50 L241 54 H252 Z"
fill="var(--paper)"
stroke="var(--ink)"
strokeWidth={2}
strokeLinejoin="round"
/>
</g>
<RedX cx={240} cy={46} arm={6} className="vls-ii-x" />
<Frame x={4} w={312} theme={theme} result />
<Pawn x={53} yb={81} s={15} eyes gaze={2} />
<BrowserWindow x={94} y={15} w={200} h={65}>
<rect x={106} y={33} width={176} height={35} rx={5} fill="var(--wall-2)" stroke={FAINT} strokeWidth={1.5} />
<InviteLink x={114} y={34} size={32} className="vls-ii-link" />
<path d="M160 44h69m-69 12h52" stroke={FAINT} strokeWidth={2} strokeLinecap="round" />
<RedX cx={264} cy={51} arm={8} />
</BrowserWindow>
</>
);
}
@@ -1080,9 +1095,10 @@ function SceneRoomFull({ theme }: { theme: ComicTheme }) {
@keyframes vlsFlBob{0%,14%{transform:translate(0,0)}20%{transform:translate(0,-3px)}26%,30%{transform:translate(0,0)}36%{transform:translate(0,-3px)}44%,100%{transform:translate(0,0)}}
@keyframes vlsFlGhost{0%,45%{opacity:.55}50%{opacity:.15}52%{opacity:.5}55%,100%{opacity:0}}
${rmBlock(
["vls-fl-c1", "vls-fl-c2", "vls-fl-c3", "vls-fl-c4", "vls-fl-c5", "vls-fl-c6", "vls-fl-c7", "vls-fl-you", "vls-fl-ghost"],
["vls-fl-c1", "vls-fl-c2", "vls-fl-c3", "vls-fl-c5", "vls-fl-c6", "vls-fl-c7", "vls-fl-you", "vls-fl-ghost"],
[[".vls-fl-ghost", "opacity:0"]],
)}
${rmBlock(["vls-fl-c4"], [[".vls-fl-c4", "transform:none"]], false)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} result />
@@ -1133,18 +1149,18 @@ function SceneBandwidthLimited({ theme }: { theme: ComicTheme }) {
.vls-bw-flow-b{animation:vlsBwFlowB var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-bw-throat{transform-box:fill-box;transform-origin:center;animation:vlsBwThroat var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-bw-small{transform-box:fill-box;transform-origin:center;animation:vlsBwSmall var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
@keyframes vlsBwFlowA{0%,12%{transform:translateX(-8px);opacity:0}26%,58%{transform:none;opacity:1}72%,100%{transform:translateX(8px);opacity:0}}
@keyframes vlsBwFlowB{0%,32%{transform:translateX(-7px);opacity:0}48%,72%{transform:none;opacity:1}86%,100%{transform:translateX(5px);opacity:0}}
@keyframes vlsBwFlowA{0%,8%{transform:translateX(-6px);opacity:.45}36%,100%{transform:none;opacity:1}}
@keyframes vlsBwFlowB{0%,18%{transform:translateX(-5px);opacity:.45}46%,100%{transform:none;opacity:1}}
@keyframes vlsBwThroat{0%,30%,100%{transform:scaleY(1)}48%,76%{transform:scaleY(.62)}}
@keyframes vlsBwSmall{0%,42%{transform:scale(1)}58%,100%{transform:scale(.82)}}
${rmBlock(
["vls-bw-flow-a", "vls-bw-flow-b", "vls-bw-throat", "vls-bw-small"],
["vls-bw-throat", "vls-bw-small"],
[
[".vls-bw-flow-a,.vls-bw-flow-b", "opacity:1;transform:none"],
[".vls-bw-throat", "transform:scaleY(.62)"],
[".vls-bw-small", "transform:scale(.82)"],
],
)}
${rmBlock(["vls-bw-flow-a", "vls-bw-flow-b"], [[".vls-bw-flow-a,.vls-bw-flow-b", "opacity:1;transform:none"]], false)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} result />
@@ -1193,16 +1209,17 @@ function SceneEncoderLimited({ theme }: { theme: ComicTheme }) {
.vls-en-small{transform-box:fill-box;transform-origin:center;animation:vlsEnSmall var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
@keyframes vlsEnFlow{0%,12%{transform:translateX(-7px);opacity:0}28%,60%{transform:none;opacity:1}76%,100%{transform:translateX(8px);opacity:0}}
@keyframes vlsEnDrop{0%,42%{transform:none;opacity:1}64%,100%{transform:translateY(16px) rotate(12deg);opacity:0}}
@keyframes vlsEnHeat{0%,34%{transform:translateY(3px);opacity:0}48%,72%{transform:none;opacity:1}86%,100%{opacity:0}}
@keyframes vlsEnHeat{0%,8%{transform:translateY(3px);opacity:.4}36%,100%{transform:none;opacity:1}}
@keyframes vlsEnSmall{0%,46%{transform:scale(1)}62%,100%{transform:scale(.82)}}
${rmBlock(
["vls-en-frame-a", "vls-en-frame-b", "vls-en-drop", "vls-en-heat", "vls-en-small"],
["vls-en-frame-a", "vls-en-frame-b", "vls-en-drop", "vls-en-small"],
[
[".vls-en-frame-a,.vls-en-frame-b,.vls-en-heat", "opacity:1;transform:none"],
[".vls-en-frame-a,.vls-en-frame-b", "opacity:1;transform:none"],
[".vls-en-drop", "opacity:0;transform:translateY(16px)"],
[".vls-en-small", "transform:scale(.82)"],
],
)}
${rmBlock(["vls-en-heat"], [[".vls-en-heat", "opacity:1;transform:none"]], false)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} result />
@@ -1255,9 +1272,10 @@ function SceneWarning({ theme }: { theme: ComicTheme }) {
@keyframes vlsWnRay{0%{opacity:0;transform:translateY(3px)}9%{opacity:1}24%{transform:translateY(-2px)}50%,100%{transform:translateY(-2px);opacity:1}}
@keyframes vlsWnBlink{0%,66%,74%,100%{transform:scaleY(1)}70%{transform:scaleY(.12)}}
${rmBlock(
["vls-wn-ray1", "vls-wn-ray2", "vls-wn-ray3", "vls-wn-eyes"],
[[".vls-wn-ray1,.vls-wn-ray2,.vls-wn-ray3", "opacity:1;transform:none"]],
["vls-wn-ray1", "vls-wn-ray3", "vls-wn-eyes"],
[[".vls-wn-ray1,.vls-wn-ray3", "opacity:1;transform:none"]],
)}
${rmBlock(["vls-wn-ray2"], [[".vls-wn-ray2", "opacity:1;transform:translateY(-2px)"]], false)}
`}</style>
<Frame x={4} w={312} theme={theme} result />
<Floor x1={24} x2={296} />
@@ -1289,6 +1307,177 @@ ${rmBlock(
);
}
function SceneMediaStatus({ theme, state }: {
theme: ComicTheme; state: "playing" | "ready" | "sharing" | "ended" | "preview-paused";
}) {
const preview = state === "preview-paused";
const colour = "var(--comic-tone, var(--ink))";
return <>
<style>{`
.vls-media-watcher{transform-origin:54px 77px;animation:vlsMediaWatch var(--comic-duration,3.2s) ease-in-out 1 both}
.vls-media-ended{animation-name:vlsMediaRest}
@keyframes vlsMediaWatch{0%{transform:none}18%{transform:translateX(3px) rotate(4deg)}42%,100%{transform:none}}
@keyframes vlsMediaRest{0%{transform:translateX(5px) rotate(3deg)}28%,100%{transform:none}}
${rmBlock(["vls-media-watcher"], [], false)}
`}</style>
<Frame x={4} w={312} theme={theme} result />
<Floor x1={24} x2={296} />
<Pawn x={54} yb={77} s={13} eyes host={state === "sharing" || state === "ended" || preview} gaze={2}
className={`vls-media-watcher${state === "ended" ? " vls-media-ended" : ""}`} />
{preview ? <>
<BrowserWindow x={92} y={18} w={106} h={60}>
<MiniTv x={115} y={38} w={60} h={30} />
<path d="M137 44v13m8-13v13" stroke={colour} strokeWidth={4} />
</BrowserWindow>
<path d="M207 49h17m-6-6 6 6-6 6" stroke={LINE} strokeWidth={2} fill="none" />
<MiniTv x={234} y={32} w={59} h={35} />
<path d="m258 41 13 8-13 8Z" fill={LIVE} />
</> : <>
<MiniTv x={130} y={21} w={124} h={53} />
{state === "ready"
? <path d="m175 47 12 11 24-26" stroke={colour} strokeWidth={4} strokeLinecap="round" strokeLinejoin="round" fill="none" />
: state === "ended"
? <rect x={182} y={36} width={23} height={23} rx={3} fill={INK_STAGE} />
: <path d="m183 32 27 16-27 16Z" fill={colour} />}
</>}
</>;
}
function SceneRoomEntry({ theme, state }: {
theme: ComicTheme; state: "closed" | "invalid" | "password";
}) {
const colour = "var(--comic-tone, var(--ink))";
return <>
<style>{`
.vls-entry-closed{transform-origin:51px 76px;animation:vlsEntryStepBack var(--comic-duration,3.2s) ease-in-out 1 both}
.vls-entry-invalid{animation:vlsEntryRefuse var(--comic-duration,3.2s) ease-in-out 1 both}
.vls-entry-key{transform-origin:215px 53px;animation:vlsEntryKey var(--comic-duration,3.2s) ease-in-out 1 both}
@keyframes vlsEntryStepBack{0%{transform:translateX(6px) rotate(4deg)}30%,100%{transform:none}}
@keyframes vlsEntryRefuse{0%{transform:none}10%{transform:translateX(-3px)}18%{transform:translateX(3px)}26%,100%{transform:none}}
@keyframes vlsEntryKey{0%{transform:translateX(10px) rotate(8deg)}32%,100%{transform:none}}
${rmBlock(["vls-entry-closed", "vls-entry-invalid", "vls-entry-key"], [], false)}
`}</style>
<Frame x={4} w={312} theme={theme} result />
<Floor x1={24} x2={296} />
<Pawn x={51} yb={76} s={12} eyes gaze={2} className={state === "closed" ? "vls-entry-closed" : undefined} />
{state === "password" ? <BrowserWindow x={95} y={15} w={195} h={62}>
<rect x={154} y={44} width={30} height={24} rx={4} fill="var(--paper)" stroke={colour} strokeWidth={2.5} />
<path d="M161 44v-8a8 8 0 0 1 16 0v8" stroke={colour} strokeWidth={2.5} fill="none" />
<g className="vls-entry-key">
<circle cx={215} cy={53} r={7} stroke={colour} strokeWidth={2.5} fill="none" />
<path d="M222 53h22m-5 0v6m-8-6v4" stroke={colour} strokeWidth={2.5} fill="none" />
</g>
</BrowserWindow> : state === "invalid" ? <>
<g className="vls-entry-invalid">{[104, 133, 162, 191].map((x, index) => <g key={x}>
<rect x={x} y={34} width={22} height={29} rx={4} fill="var(--paper)" stroke={index === 3 ? colour : LINE} strokeWidth={2} />
{index < 3 && <circle cx={x + 11} cy={48} r={3} fill={LINE} />}
</g>)}</g>
<RedX cx={251} cy={48} arm={10} />
</> : <>
<Door x={166} y={18} />
<path d="M208 31h51" stroke={colour} strokeWidth={3} />
<rect x={221} y={41} width={24} height={24} rx={3} fill={colour} />
</>}
</>;
}
function SceneBrowserAction({ theme, action }: {
theme: ComicTheme; action: "refresh" | "update" | "debug" | "export-failed" | "copy-failed" | "settings-failed";
}) {
const colour = "var(--comic-tone, var(--ink))";
return <>
<style>{`
.vls-browser-refresh{transform-origin:235px 55px;animation:vlsBrowserRefresh var(--comic-duration,3.2s) ease-in-out 1 both}
.vls-browser-update,.vls-browser-debug{animation:vlsBrowserLift var(--comic-duration,3.2s) ease-in-out 1 both}
.vls-browser-export-failed,.vls-browser-copy-failed,.vls-browser-settings-failed{animation:vlsBrowserRefuse var(--comic-duration,3.2s) ease-in-out 1 both}
@keyframes vlsBrowserRefresh{0%{transform:rotate(-360deg) scale(.72)}32%{transform:rotate(-30deg) scale(.72)}44%,100%{transform:none}}
@keyframes vlsBrowserLift{0%{transform:translateY(6px)}18%{transform:translateY(-2px)}34%,100%{transform:none}}
@keyframes vlsBrowserRefuse{0%{transform:none}10%{transform:translateX(-3px)}18%{transform:translateX(3px)}26%,100%{transform:none}}
${rmBlock(["vls-browser-refresh", "vls-browser-update", "vls-browser-debug", "vls-browser-export-failed", "vls-browser-copy-failed", "vls-browser-settings-failed"], [], false)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} result />
<BrowserWindow x={18} y={20} w={113} h={58}>
{action === "settings-failed" ? <>
<path d="M35 44h68M35 55h68M35 66h68" stroke={LINE} strokeWidth={2.5} />
<g fill="var(--paper)" stroke={LINE} strokeWidth={2.5}>
<circle cx={54} cy={44} r={4} /><circle cx={82} cy={55} r={4} /><circle cx={65} cy={66} r={4} />
</g>
</> : action === "debug" || action === "export-failed"
? <path d="M37 63V51m17 12V41m17 22V48m17 15V37" stroke={LINE} strokeWidth={7} strokeLinecap="round" />
: <path d="M33 44h63M33 55h47M33 66h63" stroke={LINE} strokeWidth={3} strokeLinecap="round" />}
</BrowserWindow>
{action === "settings-failed" ? <BrowserWindow x={179} y={20} w={113} h={58}>
<path d="M197 44h67M197 55h67M197 66h67" stroke={LINE} strokeWidth={2.5} />
<g className="vls-browser-settings-failed" fill="var(--paper)" stroke={LINE} strokeWidth={2.5}>
<circle cx={216} cy={44} r={4} /><circle cx={244} cy={55} r={4} /><circle cx={227} cy={66} r={4} />
</g>
<RedX cx={280} cy={69} arm={9} />
</BrowserWindow> : action === "refresh" || action === "debug" ? <BrowserWindow x={179} y={20} w={113} h={58}>
{action === "refresh" ? <path className="vls-browser-refresh" d="M218 44a18 18 0 1 1-1 20m1-20h14m-14 0V31" stroke={colour} strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" fill="none" /> : <>
<path className="vls-browser-debug" d="M193 64V53m15 11V44m15 20V50" stroke={LINE} strokeWidth={6} strokeLinecap="round" />
<circle cx={262} cy={52} r={11} fill="none" stroke={colour} strokeWidth={2.5} />
<circle cx={262} cy={52} r={5} fill={colour} />
</>}
</BrowserWindow> : <>
<rect x={199} y={27} width={48} height={50} rx={5} fill="var(--paper)" stroke={LINE} strokeWidth={2.5} />
<g className={`vls-browser-${action}`}>
<rect x={217} y={15} width={48} height={50} rx={5} fill="var(--paper)" stroke={colour} strokeWidth={2.5} />
<path d="M226 31h28M226 42h20M226 53h28" stroke={LINE} strokeWidth={2.5} strokeLinecap="round" />
</g>
{action === "update"
? <path d="M283 72V35m-8 8 8-8 8 8" stroke={colour} strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" fill="none" />
: <RedX cx={273} cy={70} arm={10} />}
</>}
</>;
}
function SceneSourceSwitching({ theme, state = "switching" }: {
theme: ComicTheme; state?: "switching" | "starting" | "failed";
}) {
return <>
<style>{`
.vls-source-switch{animation:vlsSourceSwitch var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-source-return{animation-name:vlsSourceReturn}
.vls-source-host{transform-origin:39px 76px;animation:vlsSourceHost var(--comic-duration,3.2s) ease-in-out 1 both}
@keyframes vlsSourceSwitch{0%{transform:translateX(-5px);opacity:.55}25%{transform:translateX(5px);opacity:1}45%,100%{transform:none;opacity:1}}
@keyframes vlsSourceReturn{0%{transform:translateX(5px);opacity:.55}25%{transform:translateX(-5px);opacity:1}45%,100%{transform:none;opacity:1}}
@keyframes vlsSourceHost{0%{transform:translateX(4px) rotate(4deg)}30%,100%{transform:none}}
${rmBlock(["vls-source-switch"], [[".vls-source-switch", "opacity:1"]])}
${rmBlock(["vls-source-host"], [], false)}
`}</style>
<Frame x={4} w={312} theme={theme} result />
<Pawn x={39} yb={76} s={10} eyes host gaze={2} className={state === "failed" ? "vls-source-host" : undefined} />
<BrowserWindow x={75} y={24} w={71} h={47}>
<path d="M87 61l18-23 11 14 8-8 12 17Z" fill={SKY} />
</BrowserWindow>
{state === "switching" ? <BrowserWindow x={225} y={24} w={71} h={47}>
<circle cx={260} cy={50} r={11} fill={SKY} />
</BrowserWindow> : <MiniTv x={225} y={26} w={71} h={43} />}
{state === "failed" ? <RedX cx={185} cy={51} arm={11} /> : <g stroke="var(--comic-tone, var(--action))" strokeWidth={3} strokeLinecap="round" strokeLinejoin="round" fill="none">
<path className="vls-source-switch" d={state === "switching" ? "M158 42h51m-9-8 9 8-9 8" : "M158 51h51m-9-8 9 8-9 8"} />
{state === "switching" && <path className="vls-source-switch vls-source-return" d="M209 61h-51m9-8-9 8 9 8" />}
</g>}
</>;
}
function SceneNameInvalid({ theme }: { theme: ComicTheme }) {
return <>
<style>{`
.vls-name-card{animation:vlsNameRefuse var(--comic-duration,3.2s) ease-in-out 1 both}
@keyframes vlsNameRefuse{0%{transform:none}10%{transform:translateX(-3px)}18%{transform:translateX(3px)}26%,100%{transform:none}}
${rmBlock(["vls-name-card"], [], false)}
`}</style>
<Frame x={4} w={312} theme={theme} result />
<Pawn x={71} yb={76} s={16} eyes gaze={2} />
<g className="vls-name-card">
<rect x={125} y={27} width={136} height={39} rx={10} fill="var(--paper)" stroke={LINE} strokeWidth={2.5} />
<path d="M142 40h66M142 52h47" stroke={LINE} strokeWidth={3} strokeLinecap="round" />
</g>
<RedX cx={246} cy={64} arm={10} />
</>;
}
/* ------------------------------ component ------------------------------ */
const SCENES: Record<ComicKind, (props: { theme: ComicTheme }) => ReactNode> = {
"waiting-for-host": SceneWaiting,
@@ -1297,6 +1486,27 @@ const SCENES: Record<ComicKind, (props: { theme: ComicTheme }) => ReactNode> = {
"signal-connecting": (p) => <SceneSignal {...p} state="connecting" />,
"signal-recovering": (p) => <SceneSignal {...p} state="recovering" />,
"signal-offline": (p) => <SceneSignal {...p} state="offline" />,
"signal-connected": (p) => <SceneSignal {...p} state="connected" />,
"transport-connected": (p) => <SceneSignal {...p} state="connected" peer />,
"signal-failed": (p) => <SceneSignal {...p} state="failed" />,
"media-playing": (p) => <SceneMediaStatus {...p} state="playing" />,
"media-ready": (p) => <SceneMediaStatus {...p} state="ready" />,
"share-live": (p) => <SceneMediaStatus {...p} state="sharing" />,
"share-ended": (p) => <SceneMediaStatus {...p} state="ended" />,
"preview-paused": (p) => <SceneMediaStatus {...p} state="preview-paused" />,
"room-closed": (p) => <SceneRoomEntry {...p} state="closed" />,
"room-code-invalid": (p) => <SceneRoomEntry {...p} state="invalid" />,
"site-access": (p) => <SceneRoomEntry {...p} state="password" />,
"page-refresh": (p) => <SceneBrowserAction {...p} action="refresh" />,
"update-available": (p) => <SceneBrowserAction {...p} action="update" />,
"debug-start": (p) => <SceneBrowserAction {...p} action="debug" />,
"debug-export-failed": (p) => <SceneBrowserAction {...p} action="export-failed" />,
"copy-failed": (p) => <SceneBrowserAction {...p} action="copy-failed" />,
"source-switching": SceneSourceSwitching,
"source-starting": (p) => <SceneSourceSwitching {...p} state="starting" />,
"source-failed": (p) => <SceneSourceSwitching {...p} state="failed" />,
"settings-failed": (p) => <SceneBrowserAction {...p} action="settings-failed" />,
"name-invalid": SceneNameInvalid,
"tap-to-play": SceneTap,
"host-paused": ScenePaused,
recovering: SceneRecovering,
@@ -1344,6 +1554,7 @@ export const Comic = memo(function Comic({
} as CSSProperties;
return (
<svg
key={kind}
viewBox="0 0 320 96"
style={style}
data-comic-tone={resolvedTone}
@@ -1352,7 +1563,7 @@ export const Comic = memo(function Comic({
focusable="false"
xmlns="http://www.w3.org/2000/svg"
>
{SCENES[kind]({ theme: resolvedTheme })}
<g className="lr-comic-content">{SCENES[kind]({ theme: resolvedTheme })}</g>
</svg>
);
});
+6 -16
View File
@@ -19,7 +19,6 @@ export interface CouchEntry {
export interface CouchHostEntry {
key: string;
name: string;
online: boolean;
you?: boolean;
selected?: boolean;
controls?: string;
@@ -32,16 +31,14 @@ export function Couch({
entries,
selectedKey,
onSelect,
emptyHint,
}: {
view: "host" | "viewer";
host?: CouchHostEntry | null;
entries: CouchEntry[];
selectedKey?: string | null;
onSelect?: (key: string) => void;
emptyHint?: string;
}) {
const { vis, t } = useCopy();
const { t } = useCopy();
const [couchRef, containerWidth] = useElementWidth();
const count = entries.length + (host ? 1 : 0);
const capacity = Math.max(2, Math.min(8, Math.floor((containerWidth - 64) / 72)));
@@ -54,7 +51,8 @@ export function Couch({
return { gridRow: row + 1, gridColumn: `${(index % columns) * 2 + 1 + columns - inRow} / span 2` };
};
const hostLabel = host
? `${host.name} · ${t("common.host")}${host.you ? ` · ${t("common.you")}` : ""} · ${t(host.online ? "state.presence.online" : "state.presence.offline")}`
? [host.name, host.name === t("common.host") ? null : t("common.host"),
host.you ? t("common.you") : null].filter(Boolean).join(" · ")
: undefined;
return (
@@ -78,7 +76,7 @@ export function Couch({
aria-label={`${t("common.host")} · ${t("common.viewers")}`}
>
{host ? (
<span className="lr-seat" style={seatStyle(0)}><Tooltip text={vis ? host.name : hostLabel}>
<span className="lr-seat" style={seatStyle(0)}><Tooltip overflow={{ text: host.name, selector: ".lr-pawn-name" }}>
{host.onSelect ? (
<button
type="button"
@@ -96,7 +94,6 @@ export function Couch({
className={`lr-pawn is-host is-static${host.you ? " is-you" : ""}`}
role="img"
aria-label={hostLabel}
tabIndex={0}
>
<PawnSvg color={participantColor(host.key)} identity={host.key} host />
<span className="lr-pawn-name">{host.name}</span>
@@ -106,7 +103,6 @@ export function Couch({
) : null}
{entries.map((entry, index) => {
const stateLabel = t(entry.status.labelKey);
const hint = entry.status.tooltip ?? entry.status.comic;
const label = `${entry.name}${entry.you ? ` · ${t("common.you")}` : ""} · ${stateLabel}`;
const inner = (
<>
@@ -126,7 +122,6 @@ export function Couch({
className={className}
role="img"
aria-label={label}
tabIndex={0}
>
{inner}
</span>
@@ -142,12 +137,7 @@ export function Couch({
</button>
);
return (
<span key={entry.key} className="lr-seat" style={seatStyle(index + (host ? 1 : 0))}><Tooltip
kind={hint}
tone={entry.status.tone}
motion={entry.status.pulse ? "progress" : undefined}
text={vis ? hint ? undefined : entry.name : label}
>
<span key={entry.key} className="lr-seat" style={seatStyle(index + (host ? 1 : 0))}><Tooltip overflow={{ text: entry.name, selector: ".lr-pawn-name" }}>
{pawn}
</Tooltip></span>
);
@@ -163,7 +153,7 @@ export function Couch({
<div
className="lr-couch-empty"
role="img"
aria-label={emptyHint ?? t("host.viewers.empty")}
aria-label={t("host.viewers.empty")}
>
<Glyph name="users" size={22} />
</div>
+10 -12
View File
@@ -19,14 +19,12 @@ export function LedStrip({
}: {
state: LedState;
label: string;
comic?: ComicKind;
comic: ComicKind;
}) {
const { vis } = useCopy();
const wrapped = !vis || comic;
const Trigger = wrapped ? "button" : "span";
const strip = (
<Trigger
type={wrapped ? "button" : undefined}
<button
type="button"
className="lr-leds"
data-state={state}
aria-label={label}
@@ -35,15 +33,13 @@ export function LedStrip({
<i />
<i />
{vis ? null : <span className="lr-leds-label">{label}</span>}
</Trigger>
</button>
);
return (
<span role="status">
{wrapped ? (
<Tooltip toggleOnClick kind={comic} tone={state} text={vis ? undefined : label} place="below" align="start">
{strip}
</Tooltip>
) : strip}
<Tooltip toggleOnClick kind={comic} tone={state} motion={state === "busy" || state === "warn" ? "progress" : "still"} text={vis ? undefined : label} place="below" align="start">
{strip}
</Tooltip>
</span>
);
}
@@ -109,7 +105,9 @@ export function HeaderControls({ diagnosticControl }: { diagnosticControl?: Reac
{themeButton}
</Tooltip>
{diagnosticControl !== null && <span className="lr-header-diagnostic">
{diagnosticControl === undefined ? <Tooltip kind={browserDebugEnabled ? "hint-debug-export" : "hint-details"}
{diagnosticControl === undefined ? <Tooltip kind={!browserDebugEnabled ? "debug-start" : debugExport === "failed" ? "debug-export-failed" : "hint-debug-export"}
tone={debugExport === "failed" ? "bad" : debugExport === "busy" ? "busy" : "off"}
motion={debugExport === "failed" ? "still" : debugExport === "busy" ? "progress" : "demo"}
text={vis ? undefined : debugTitle} place="below" align="end">
{debugButton}
</Tooltip> : diagnosticControl}
+12 -4
View File
@@ -67,9 +67,9 @@ export function LauncherForm({
const { vis, t } = useCopy();
const accessField = (
<label className="lr-input lr-client-access">
<Glyph name="lock" size={18} />
<Glyph name="key" size={18} />
<input
type="text"
type="password"
value={localAccessPassword}
autoComplete="off"
spellCheck={false}
@@ -95,6 +95,14 @@ export function LauncherForm({
className="lr-client-modes"
role="radiogroup"
aria-label={t("client.launch.title")}
onKeyDown={(event) => {
if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
event.preventDefault();
const direction = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : -1;
const index = (MODES.findIndex((choice) => choice.mode === mode) + direction + MODES.length) % MODES.length;
onModeChange(MODES[index]!.mode);
event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="radio"]')[index]?.focus();
}}
>
{MODES.map((choice) => {
const button = (
@@ -104,6 +112,7 @@ export function LauncherForm({
role="radio"
className={`lr-client-mode${mode === choice.mode ? " is-selected" : ""}`}
aria-checked={mode === choice.mode}
tabIndex={mode === choice.mode ? 0 : -1}
aria-label={t(choice.label)}
onClick={() => {
onModeChange(choice.mode);
@@ -134,7 +143,7 @@ export function LauncherForm({
<fieldset className="lr-client-lan">
<legend className={vis ? "visually-hidden" : undefined}>{t("client.launch.lanAddress")}</legend>
{lan.addresses.length === 0 ? (
<Pill icon="alert" comic="warning" label={t("client.launch.lanUnavailable")} />
<Pill icon="wifiOff" tone="warn" comic="signal-offline" label={t("client.launch.lanUnavailable")} />
) : <>
{vis ? null : <p className="lr-client-lan-hint">{t("client.launch.lanHint")}</p>}
<div className="lr-client-lan-options">
@@ -156,7 +165,6 @@ export function LauncherForm({
<input
type="url"
value={site}
autoFocus
spellCheck={false}
inputMode="url"
placeholder="https://share.example"
+72 -74
View File
@@ -1,21 +1,36 @@
// Connection metrics as living-room meter cells: glyph + value, plus the
// label in text modes. Secondary metrics expand behind a chevron.
import { useId, useState, type ReactNode } from "react";
import { useId, useState } from "react";
import { Glyph, type GlyphName } from "../../ui/icons";
import { useCopy, type CopyKey } from "../../ui/copy";
import { Tooltip } from "./Tooltip";
import { Pill } from "./primitives";
import type { ConnectionMetrics } from "../../types";
import { formatPacketLossPercent } from "../connection-details";
import { METRIC_PRESENTATION, type MetricLabel } from "./metric-presentation";
import type { ComicKind, HintKind } from "../../ui/visual-kinds";
import type { ComicTone } from "./comic-presentation";
interface MetricValue {
icon: GlyphName;
label: CopyKey;
icon?: GlyphName;
label: MetricLabel;
value: string;
hint?: ComicKind | HintKind;
tone?: ComicTone;
/** Visual mode shows the glyph alone; the value stays in the a11y name. */
glyphOnly?: boolean;
}
// One display for each observed sender reason. Unknown reasons remain explicit;
// none describes this field, not a verdict about the whole connection.
const QUALITY_REASONS = {
none: { valueKey: "stats.quality.normal", icon: "check", hint: "hint-metric-quality", tone: "off" },
bandwidth: { valueKey: "stats.quality.bandwidth", icon: "gauge", hint: "bandwidth-limited", tone: "warn" },
cpu: { valueKey: "stats.quality.cpu", icon: "cpu", hint: "encoder-limited", tone: "warn" },
other: { valueKey: "stats.quality.other", icon: "alert", hint: "hint-metric-quality", tone: "warn" },
unclassified: { valueKey: "stats.quality.unclassified", icon: "alert", hint: "hint-metric-quality", tone: "warn" },
} satisfies Record<string, { valueKey: CopyKey; icon: GlyphName; hint: ComicKind | HintKind; tone: ComicTone }>;
export function codecContractWarnings(
metrics: ConnectionMetrics,
t: (key: CopyKey, vars?: Record<string, string>) => string,
@@ -43,50 +58,33 @@ function secondaryMetrics(
const readable = (value: number | null, digits = 0): string =>
value === null || !Number.isFinite(value) ? t("stats.unknown") : value.toFixed(digits);
const addNumber = (
label: CopyKey,
icon: GlyphName,
label: MetricLabel,
value: number | null,
unit: string,
digits = 0,
) => {
if (value !== null && Number.isFinite(value)) {
values.push({ icon, label, value: `${readable(value, digits)} ${unit}` });
values.push({ label, value: `${readable(value, digits)} ${unit}` });
}
};
const qualityReason = (value: string): string => {
const keys: Record<string, CopyKey> = {
none: "stats.quality.normal",
bandwidth: "stats.quality.bandwidth",
cpu: "stats.quality.cpu",
other: "stats.quality.other",
};
return t(keys[value] ?? "stats.quality.unclassified");
};
const qualityReasonIcon = (value: string): GlyphName =>
value === "none"
? "check"
: value === "bandwidth"
? "gauge"
: value === "cpu"
? "cpu"
: "alert";
addNumber("stats.rtt", "clock", metrics.rttMs, "ms");
addNumber("stats.rtt", metrics.rttMs, "ms");
if (metrics.codec) {
values.push({
icon: "cpu",
label: "stats.codec",
value: metrics.codec.split("/").at(-1) ?? metrics.codec,
});
}
if (direction === "send") {
addNumber("stats.outgoing", "gauge", metrics.availableOutgoingKbps, "kbps");
addNumber("stats.outgoing", metrics.availableOutgoingKbps, "kbps");
if (metrics.qualityLimitationReason) {
const { valueKey, ...presentation } = Object.hasOwn(QUALITY_REASONS, metrics.qualityLimitationReason)
? QUALITY_REASONS[metrics.qualityLimitationReason as keyof typeof QUALITY_REASONS]
: QUALITY_REASONS.unclassified;
values.push({
icon: qualityReasonIcon(metrics.qualityLimitationReason),
...presentation,
label: "stats.qualityState",
value: qualityReason(metrics.qualityLimitationReason),
value: t(valueKey),
// A limitation reason is a sentence, not a measured value: visual
// mode states it with the glyph and keeps the words for AT.
glyphOnly: true,
@@ -110,12 +108,11 @@ function secondaryMetrics(
}
}
if (captureParts.length > 0) {
values.push({ icon: "expand", label: "stats.capture", value: captureParts.join(" · ") });
values.push({ label: "stats.capture", value: captureParts.join(" · ") });
}
addNumber("stats.inputFps", "wave", metrics.mediaSourceFramesPerSecond, "fps", 1);
addNumber("stats.inputFps", metrics.mediaSourceFramesPerSecond, "fps", 1);
if (metrics.encoderImplementation) {
values.push({
icon: "cpu",
label: "stats.encoder",
value: `${metrics.encoderImplementation}${
metrics.powerEfficientEncoder === true
@@ -126,42 +123,67 @@ function secondaryMetrics(
}`,
});
}
addNumber("stats.encodeMs", "clock", metrics.intervalEncodeMs, "ms", 1);
addNumber("stats.encodeMs", metrics.intervalEncodeMs, "ms", 1);
} else {
addNumber("stats.jitter", "wave", metrics.jitterMs, "ms", 1);
addNumber("stats.dropped", "drop", metrics.framesDropped, "", 0);
addNumber("stats.decodeMs", "clock", metrics.intervalDecodeMs, "ms", 1);
addNumber("stats.freezeCount", "alert", metrics.intervalFreezeCount, "", 0);
addNumber("stats.freezeDuration", "clock", metrics.intervalFreezeDurationMs, "ms", 1);
addNumber("stats.jitter", metrics.jitterMs, "ms", 1);
addNumber("stats.dropped", metrics.intervalFramesDropped, "", 0);
addNumber("stats.decodeMs", metrics.intervalDecodeMs, "ms", 1);
addNumber("stats.freezeCount", metrics.intervalFreezeCount, "", 0);
addNumber("stats.freezeDuration", metrics.intervalFreezeDurationMs, "ms", 1);
}
addNumber("stats.audio", "speaker", metrics.audioBitrateKbps, "kbps");
addNumber("stats.audio", metrics.audioBitrateKbps, "kbps");
if (metrics.audioPacketLossPercent !== null) {
values.push({
icon: "drop",
label: "stats.audioLoss",
value: formatPacketLossPercent(metrics.audioPacketLossPercent, t("stats.unknown")),
});
}
addNumber("stats.audioJitter", "wave", metrics.audioJitterMs, "ms", 1);
addNumber("stats.audioJitter", metrics.audioJitterMs, "ms", 1);
if (direction === "receive") {
addNumber("stats.playoutDelta", "clock", metrics.audioVideoPlayoutDeltaMs, "ms", 1);
addNumber("stats.videoBuffer", "clock", metrics.videoJitterBufferDelayMs, "ms", 1);
addNumber("stats.audioBuffer", "clock", metrics.audioJitterBufferDelayMs, "ms", 1);
addNumber("stats.playoutDelta", metrics.audioVideoPlayoutDeltaMs, "ms", 1);
addNumber("stats.videoBuffer", metrics.videoJitterBufferDelayMs, "ms", 1);
addNumber("stats.audioBuffer", metrics.audioJitterBufferDelayMs, "ms", 1);
if (metrics.audioConcealedSamplesPercent !== null) {
values.push({
icon: "drop",
label: "stats.audioConcealedRate",
value: formatPacketLossPercent(metrics.audioConcealedSamplesPercent, t("stats.unknown")),
});
}
addNumber("stats.audioConcealed", "speaker", metrics.intervalAudioConcealmentEvents, "", 0);
addNumber("stats.audioConcealed", metrics.intervalAudioConcealmentEvents, "", 0);
}
return values;
}
export function MetricCell({ icon, label, value, glyphOnly, hint, tone = "off" }: MetricValue) {
const { t, vis } = useCopy();
const display = vis && value === t("stats.unknown") ? "—" : value;
return (
<Tooltip toggleOnClick kind={hint ?? METRIC_PRESENTATION[label].hint} tone={tone} motion={tone === "warn" ? "still" : "demo"}
text={vis ? undefined : `${t(label)} · ${display}`}>
<button type="button" className="lr-meter-cell" aria-label={`${t(label)} · ${display}`}
style={{ border: 0, color: "inherit", font: "inherit", textAlign: "start", cursor: "help" }}>
<Glyph name={icon ?? METRIC_PRESENTATION[label].icon} size={16} />
{vis ? (
<>
{glyphOnly ? null : <b>{display}</b>}
<span className="visually-hidden">
{glyphOnly ? [t(label), display].join(" · ") : t(label)}
</span>
</>
) : (
<span className="lr-meter-text">
<b>{display}</b>
<small>{t(label)}</small>
</span>
)}
</button>
</Tooltip>
);
}
export function MetricCells({
metrics,
direction,
@@ -174,45 +196,21 @@ export function MetricCells({
onToggle: (expanded: boolean) => void;
}) {
const { t, vis } = useCopy();
const cell = ({ icon, label, value, glyphOnly }: MetricValue): ReactNode => {
const display = vis && value === t("stats.unknown") ? "—" : value;
return (
<span className="lr-meter-cell" key={label + display}>
<Glyph name={icon} size={16} />
{vis ? (
<>
{glyphOnly ? null : <b>{display}</b>}
<span className="visually-hidden">
{glyphOnly ? [t(label), display].join(" · ") : t(label)}
</span>
</>
) : (
<span className="lr-meter-text">
<b>{display}</b>
<small>{t(label)}</small>
</span>
)}
</span>
);
};
const primary: MetricValue[] = [
{ icon: "expand", label: "stats.resolution", value: metrics.resolution ?? t("stats.unknown") },
{ label: "stats.resolution", value: metrics.resolution ?? t("stats.unknown") },
{
icon: "wave",
label: "stats.fps",
value: metrics.framesPerSecond === null || !Number.isFinite(metrics.framesPerSecond)
? t("stats.unknown")
: `${metrics.framesPerSecond.toFixed(1)} fps`,
},
{
icon: "gauge",
label: "stats.bitrate",
value: metrics.bitrateKbps === null || !Number.isFinite(metrics.bitrateKbps)
? t("stats.unknown")
: `${metrics.bitrateKbps.toFixed(0)} kbps`,
},
{ icon: "drop", label: "stats.loss", value: formatPacketLossPercent(metrics.packetLossPercent, t("stats.unknown")) },
{ label: "stats.loss", value: formatPacketLossPercent(metrics.packetLossPercent, t("stats.unknown")) },
];
const secondary = secondaryMetrics(metrics, direction, t);
const warnings = codecContractWarnings(metrics, t);
@@ -239,7 +237,7 @@ export function MetricCells({
return (
<>
<div className="lr-meter" role="group" aria-label={t("stats.title")}>
{primary.map(cell)}
{primary.map((value) => <MetricCell key={value.label} {...value} />)}
</div>
{secondary.length > 0 ? (
<>
@@ -248,7 +246,7 @@ export function MetricCells({
</Tooltip>
{expanded ? (
<div id={secondaryId} className="lr-meter" role="group" aria-label={t("stats.more")}>
{secondary.map(cell)}
{secondary.map((value) => <MetricCell key={value.label} {...value} />)}
</div>
) : null}
</>
+7 -11
View File
@@ -19,16 +19,16 @@ export function RouteGlyph({ route }: { route: "p2p" | "sfu" }) {
tabIndex={0}
>
{route === "sfu" ? (
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" stroke="#53676a" strokeWidth="2" aria-hidden="true">
<circle cx="4" cy="8" r="3" fill="#53676a" stroke="none" />
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<circle cx="4" cy="8" r="3" fill="currentColor" stroke="none" />
<rect x="14" y="3" width="8" height="10" rx="2" />
<circle cx="32" cy="8" r="3" fill="#53676a" stroke="none" />
<circle cx="32" cy="8" r="3" fill="currentColor" stroke="none" />
<path d="M7 8h7M22 8h7" />
</svg>
) : (
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" stroke="#53676a" strokeWidth="2" aria-hidden="true">
<circle cx="4" cy="8" r="3" fill="#53676a" stroke="none" />
<circle cx="32" cy="8" r="3" fill="#53676a" stroke="none" />
<svg width="36" height="16" viewBox="0 0 36 16" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<circle cx="4" cy="8" r="3" fill="currentColor" stroke="none" />
<circle cx="32" cy="8" r="3" fill="currentColor" stroke="none" />
<path d="M7 8h22" />
</svg>
)}
@@ -82,11 +82,7 @@ export function PawnDetail({
<span className="lr-pawn-mini">
<PawnSvg color={participantColor(pawnKey)} identity={pawnKey} />
</span>
{vis ? (
<span className="visually-hidden">{name}</span>
) : (
<span className="lr-pawn-detail-name">{name}</span>
)}
<span className="lr-pawn-detail-name">{name}</span>
{tag ? (
<span className="lr-meter-cell">
<Glyph name={tag.icon} size={16} />
@@ -152,7 +152,7 @@ export function PlaybackControls({
hintTone={picture.failed ? "bad" : undefined}
title={picture.failed ? "playback.pipFailed" : !picture.supported ? "playback.pipUnavailable"
: picture.active ? "playback.exitPip" : !canPlay ? "playback.pipWaiting" : "playback.pip"}
hint={!picture.supported || picture.failed ? "hint-pip-unavailable" : picture.active ? "hint-pip-exit" : "hint-pip"}
hint={!picture.supported || picture.failed || (!picture.active && !canPlay) ? "hint-pip-unavailable" : picture.active ? "hint-pip-exit" : "hint-pip"}
pressed={picture.active} disabled={!picture.supported || (!picture.active && !canPlay)}
onClick={() => { void picture.toggle(); }} />
{!fullscreen.active ? <Btn icon={theaterMode ? "theaterExit" : "theater"}
@@ -0,0 +1,27 @@
import { QUALITY_PROFILES, QUALITY_PROFILE_KEYS, type QualityProfileId } from "../../media/quality";
import { useCopy } from "../../ui/copy";
import { QualityTileGlyph } from "./QualityTileGlyph";
import { Tooltip } from "./Tooltip";
export function QualityPresets({ selected, disabled = false, busy = false, onSelect }: {
selected: QualityProfileId | null;
disabled?: boolean;
busy?: boolean;
onSelect: (id: QualityProfileId) => void;
}) {
const { t, vis } = useCopy();
return <div className="lr-tiles" aria-busy={busy}>
{(Object.keys(QUALITY_PROFILES) as QualityProfileId[]).map((id, index) => {
const profile = QUALITY_PROFILES[id];
const caption = t(QUALITY_PROFILE_KEYS[id]);
return <Tooltip key={id} kind="hint-quality" align={index === 0 ? "start" : "center"}
text={vis ? undefined : t("host.quality.title", { label: caption, mbps: (profile.maxBitrate / 1_000_000).toFixed(0) })}>
<button type="button" className={`lr-tile${selected === id ? " is-selected" : ""}`}
aria-pressed={selected === id} aria-label={caption} disabled={disabled} onClick={() => onSelect(id)}>
<QualityTileGlyph resolution={profile.resolution} framerate={profile.maxFramerate} />
<small>{vis ? `${profile.resolution.replace("p", "")}·${profile.maxFramerate}` : caption}</small>
</button>
</Tooltip>;
})}
</div>;
}
@@ -0,0 +1,11 @@
// Resolution controls scanline density; frame rate adds motion to that same image.
export function QualityTileGlyph({ resolution, framerate }: { resolution: string; framerate: number }) {
const lines = resolution === "720p" ? 2 : 3;
return <svg width={28} height={38} viewBox="0 0 28 38" fill="none" aria-hidden="true">
<rect x={2} y={3} width={24} height={26} rx={4} stroke="currentColor" strokeWidth={2.2} />
{Array.from({ length: lines }, (_, index) => <path key={index}
d={`M6 ${10 + index * 5}h16`} stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" />)}
{framerate > 30 ? <path d="M7 33h4m3 0h7m-3-2 3 2-3 2"
stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round" /> : null}
</svg>;
}
+25 -23
View File
@@ -75,7 +75,7 @@ export function Lcd({ code }: { code: string }) {
</span>
))}
</span>
<Tooltip kind={copyState === "failed" ? "warning" : "hint-copy-code"}
<Tooltip kind={copyState === "failed" ? "copy-failed" : "hint-copy-code"}
tone={copyState === "copied" ? "live" : copyState === "failed" ? "bad" : "off"}
motion={copyState === "idle" ? "demo" : "still"}
text={vis ? undefined : copyFeedback || t("common.copy")}>
@@ -151,6 +151,28 @@ export function RoomChip({
);
}
export function roomAdmission(policy: CodeEntryPolicy, passwordEnabled: boolean) {
return (
policy === "open"
? {
icon: "globe" as const,
label: "host.policy.currentOpen" as const,
comic: "hint-admission-code" as const,
}
: passwordEnabled
? {
icon: "key" as const,
label: "host.policy.currentPassword" as const,
comic: "hint-admission-password" as const,
}
: {
icon: "lock" as const,
label: "host.policy.currentInvite" as const,
comic: "hint-admission-invite" as const,
}
);
}
export function RoomAdmissionBadge({
policy,
passwordEnabled,
@@ -159,31 +181,11 @@ export function RoomAdmissionBadge({
passwordEnabled: boolean;
}) {
const { t } = useCopy();
const presentation =
policy === "open"
? {
icon: "globe" as const,
label: "host.policy.currentOpen" as const,
tone: "good" as const,
comic: "hint-policy-open" as const,
}
: passwordEnabled
? {
icon: "key" as const,
label: "host.policy.currentPassword" as const,
tone: "good" as const,
comic: "hint-password" as const,
}
: {
icon: "lock" as const,
label: "host.policy.currentInvite" as const,
tone: undefined,
comic: "hint-policy-private" as const,
};
const presentation = roomAdmission(policy, passwordEnabled);
return (
<Pill
icon={presentation.icon}
tone={presentation.tone}
tone="off"
label={t(presentation.label)}
comic={presentation.comic}
/>
@@ -0,0 +1,32 @@
import { useCopy } from "../../ui/copy";
import { Comic } from "./Comic";
export function RoomCodeInput({ value, onChange, rejectedAttempt = 0, autoFocus = false }: {
value: string;
onChange: (value: string) => void;
rejectedAttempt?: number;
autoFocus?: boolean;
}) {
const { t } = useCopy();
return <div key={rejectedAttempt} className={`lr-dials-wrap${rejectedAttempt ? " is-shake" : ""}`}>
<div className="lr-dials" aria-hidden="true">
{[0, 1, 2, 3].map(index => <span key={index}
className={`lr-dial${value[index] ? " is-filled" : index === value.length ? " is-active" : ""}`}>
{value[index] ?? ""}
</span>)}
</div>
<input value={value} inputMode="numeric" autoComplete="off" maxLength={4}
autoFocus={autoFocus} aria-label={t("join.field")} aria-invalid={rejectedAttempt > 0}
onChange={event => onChange(event.target.value.replace(/\D/g, "").slice(0, 4))} />
</div>;
}
export function RoomCodeError({ attempt, theme = "paper" }: { attempt: number; theme?: "paper" | "stage" }) {
const { t, vis } = useCopy();
if (!attempt) return null;
// The message is inside the live region; another rejection re-announces it.
return <span key={attempt} role="alert" style={{ display: "grid", justifyItems: "center", gap: 8 }}>
<Comic kind="room-code-invalid" theme={theme} size={240} />
<span className={vis ? "visually-hidden" : "lr-cap"} style={{ color: "var(--danger)" }}>{t("join.invalid")}</span>
</span>;
}
+40 -115
View File
@@ -11,6 +11,7 @@ import {
} from "../../lib/participant-topology";
import { useCopy } from "../../ui/copy";
import { PawnSvg } from "./Pawn";
import { Tooltip } from "./Tooltip";
import { participantColor } from "./participant-color";
import {
topologyLayoutForWidth,
@@ -23,6 +24,7 @@ interface TreeNode {
via: string | null;
sfu: boolean;
you: boolean;
// Unready media is pending, including the initial connection.
ready: boolean;
}
@@ -76,26 +78,12 @@ function compactVisibleLabel(label: string, maximumCodePoints: number): string {
return `${codePoints.slice(0, maximumCodePoints - 1).join("")}…`;
}
function topologyVisibleLabel(
label: string,
peerId: string | null,
maximumCodePoints: number,
): string {
const peerIdSuffix = peerId?.slice(-6) ?? "";
const withoutRedundantRole =
peerIdSuffix &&
(label === `🎮 (${peerIdSuffix})` || label === `👤 (${peerIdSuffix})`)
? peerIdSuffix
: label;
return compactVisibleLabel(withoutRedundantRole, maximumCodePoints);
}
function xForDepth(depth: number, layout: TopologyLayout): number {
return layout.hostX + (depth + 1) * layout.columnGap;
}
function edgeClass(kind: "p2p" | "sfu", ready = true): string {
return `lr-route-edge is-${kind}${ready ? "" : " is-recovering"}`;
return `lr-route-edge is-${kind}${ready ? "" : " is-pending"}`;
}
export const RouteTree = memo(function RouteTree({
@@ -285,7 +273,37 @@ export const RouteTree = memo(function RouteTree({
const labelAnchor = outline ? "start" : "middle";
const labelLimit = (point: { x: number }) => outline
? Math.min(layoutConfig.maxVisibleLabelCodePoints, Math.floor((width - point.x - 40) / 11))
: Math.min(layoutConfig.maxVisibleLabelCodePoints, Math.floor((fittedGap - 12) / 11));
: Math.min(layoutConfig.maxVisibleLabelCodePoints,
Math.floor((Math.min(fittedGap, point.x * 2, (width - point.x) * 2) - 12) / 11));
const renderName = (key: string, label: string, point: { x: number; y: number }, state = "") => {
const canSelect = selectable.has(key);
const Control = canSelect ? "button" : "span";
// Keep the original hit box and row pitch; native buttons own Enter/Space.
// HTML lets the shared Tooltip measure the label and use the top layer.
return <foreignObject key={`name-${key}`} className="lr-route-name-target"
x={outline ? point.x - 24 : point.x - 48} y={point.y - 26}
width={outline ? width - point.x + 16 : 96} height={spacing}>
<Tooltip overflow={{ text: label, selector: ".lr-route-name" }} className="lr-route-name-hint">
<Control className={canSelect ? "lr-route-hit" : "lr-route-name-static"}
type={canSelect ? "button" : undefined}
aria-label={canSelect ? `${label} · ${t("host.details")}` : undefined}
onPointerEnter={canSelect ? () => setHoveredPeerId(key) : undefined}
onPointerLeave={canSelect ? () => setHoveredPeerId(current => current === key ? null : current) : undefined}
onFocus={canSelect ? () => setHoveredPeerId(key) : undefined}
onBlur={canSelect ? () => setHoveredPeerId(current => current === key ? null : current) : undefined}
onClick={canSelect ? event => {
selectPeer(key);
if (event.detail !== 0) event.currentTarget.blur();
} : undefined}>
<span className={`lr-route-label lr-route-name${state}${hoveredPeerId === key ? " is-hovered" : ""}${selectedPeerId === key ? " is-selected" : ""}`}
style={{ left: outline ? 52 : "50%", top: Math.min(spacing - 14, labelY(point) - point.y + 14),
width: outline ? width - point.x - 36 : labelLimit(point) * 11 }}>
{compactVisibleLabel(label, labelLimit(point))}
</span>
</Control>
</Tooltip>
</foreignObject>;
};
const linkPath = (parent: { x: number; y: number }, child: { x: number; y: number }) => outline
? `M ${parent.x - 17} ${parent.y} H ${parent.x - 28} V ${child.y} H ${child.x - 18}`
: `M ${parent.x + 20} ${parent.y} Q ${(parent.x + child.x) / 2} ${parent.y + (child.y - parent.y) * 0.55}, ${child.x - 18} ${child.y}`;
@@ -316,9 +334,7 @@ export const RouteTree = memo(function RouteTree({
</span>
<svg
viewBox={`0 0 ${width} ${height}`}
aria-hidden={onSelectPeer ? undefined : true}
aria-label={onSelectPeer ? t("host.topology") : undefined}
focusable={onSelectPeer ? undefined : "false"}
aria-label={t("host.topology")}
style={{ width: "100%", maxWidth: outline || nodes.length > 10 ? layoutConfig.baseWidth : 880 }}
>
{nodes
@@ -387,18 +403,6 @@ export const RouteTree = memo(function RouteTree({
host
/>
</g>
<text
className="lr-route-label is-host"
x={labelX(hostPos)}
y={labelY(hostPos)}
textAnchor={labelAnchor}
>
{topologyVisibleLabel(
hostLabel,
hostPeerId,
labelLimit(hostPos),
)}
</text>
{sfuPos ? (
<>
@@ -427,7 +431,7 @@ export const RouteTree = memo(function RouteTree({
return (
<g
key={node.key}
className={`lr-route-node${node.ready ? "" : " is-recovering"}${hovered ? " is-hovered" : ""}${selected ? " is-selected" : ""}`}
className={`lr-route-node${node.ready ? "" : " is-pending"}${hovered ? " is-hovered" : ""}${selected ? " is-selected" : ""}`}
transform={`translate(${centeredPawnX(point.x, PAWN_SCALE)}, ${centeredPawnY(point.y, PAWN_SCALE)}) scale(${PAWN_SCALE})`}
>
<PawnSvg color={participantColor(node.key)} identity={node.key} />
@@ -450,7 +454,7 @@ export const RouteTree = memo(function RouteTree({
return (
<g
key={`pending-${point.viewer.peerId}`}
className={`lr-route-node is-recovering${hovered ? " is-hovered" : ""}${selected ? " is-selected" : ""}`}
className={`lr-route-node is-pending${hovered ? " is-hovered" : ""}${selected ? " is-selected" : ""}`}
transform={`translate(${centeredPawnX(point.x, PAWN_SCALE)}, ${centeredPawnY(point.y, PAWN_SCALE)}) scale(${PAWN_SCALE})`}
>
<PawnSvg color={participantColor(point.viewer.peerId)} identity={point.viewer.peerId} />
@@ -467,88 +471,9 @@ export const RouteTree = memo(function RouteTree({
);
})}
{nodes.map((node) => {
const point = pos.get(node.key)!;
return (
<text
key={`label-${node.key}`}
className={`lr-route-label${hoveredPeerId === node.key ? " is-hovered" : ""}${selectedPeerId === node.key ? " is-selected" : ""}`}
x={labelX(point)}
y={labelY(point)}
textAnchor={labelAnchor}
>
{topologyVisibleLabel(
node.label,
node.key,
labelLimit(point),
)}
</text>
);
})}
{pendingPos.map((point) => (
<text
key={`label-pending-${point.viewer.peerId}`}
className={`lr-route-label is-recovering${hoveredPeerId === point.viewer.peerId ? " is-hovered" : ""}${selectedPeerId === point.viewer.peerId ? " is-selected" : ""}`}
x={labelX(point)}
y={labelY(point)}
textAnchor={labelAnchor}
>
{topologyVisibleLabel(
point.viewer.label,
point.viewer.peerId,
labelLimit(point),
)}
</text>
))}
{[...nodes, ...pendingPos.map(({ viewer }) => ({
key: viewer.peerId,
label: viewer.label,
}))]
.filter((node) => selectable.has(node.key))
.map((node) => {
const point = pos.get(node.key) ?? pendingPos.find(
(pending) => pending.viewer.peerId === node.key,
);
if (!point) return null;
return (
<rect
key={`hit-${node.key}`}
className="lr-route-hit"
x={outline ? point.x - 24 : point.x - 48}
// Bound the target to the row pitch: a taller rect would cover
// the next row's label and steal its clicks (later rect wins).
y={point.y - 26}
width={outline ? width - point.x + 16 : 96}
height={spacing}
rx={12}
role="button"
tabIndex={0}
aria-label={`${node.label} · ${t("host.details")}`}
onPointerEnter={() => setHoveredPeerId(node.key)}
onPointerLeave={() =>
setHoveredPeerId((current) =>
current === node.key ? null : current,
)
}
onFocus={() => setHoveredPeerId(node.key)}
onBlur={() =>
setHoveredPeerId((current) =>
current === node.key ? null : current,
)
}
onClick={(event) => {
selectPeer(node.key);
if (event.detail !== 0) event.currentTarget.blur();
}}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
selectPeer(node.key);
}
}}
/>
);
})}
{renderName(hostPeerId ?? "host", hostLabel, hostPos, " is-host")}
{nodes.map(node => renderName(node.key, node.label, pos.get(node.key)!))}
{pendingPos.map(point => renderName(point.viewer.peerId, point.viewer.label, point, " is-pending"))}
</svg>
<ul className="visually-hidden">
+3 -43
View File
@@ -1,12 +1,11 @@
// The TV stage: frame, screen, status slot. Pages render their own <video>
// (stream binding stays with the page); overlays and the connecting
// storyboard are provided here.
// (stream binding stays with the page); shared overlays are provided here.
import type { ReactNode } from "react";
import { Glyph, type GlyphName } from "../../ui/icons";
import { useCopy, type CopyKey } from "../../ui/copy";
import { useCopy } from "../../ui/copy";
import { Comic, type ComicKind } from "./Comic";
import type { ComicTone } from "./comic-presentation";
import { BrandLoader, BrandMark } from "./BrandMark";
import { BrandLoader } from "./BrandMark";
export function StageTv({
hasEntry,
@@ -39,45 +38,6 @@ export function StaticNoise() {
return <div className="lr-tv-static" aria-hidden="true" />;
}
export function StoryBoard({
step,
showBrand = false,
}: {
step: 0 | 1 | 2;
showBrand?: boolean;
}) {
const { vis, t } = useCopy();
const panels: [GlyphName, CopyKey][] = [
["door", "story.room"],
["plug", "story.link"],
["tv", "story.show"],
];
return (
<div className="lr-storyboard" role="status" aria-label={t("host.starting")}>
{showBrand ? <BrandMark size={40} motion="loop" /> : null}
<div className="lr-story" aria-hidden="true">
{panels.map(([icon, key], index) => (
<span key={key} style={{ display: "contents" }}>
{index > 0 ? (
<span className={`lr-story-link${step > index - 1 ? " is-done" : ""}`} />
) : null}
<span
className={`lr-story-item${step > index ? " is-done" : step === index ? " is-now" : ""}`}
>
<span
className={`lr-story-panel${step > index ? " is-done" : step === index ? " is-now" : ""}`}
>
<Glyph name={step > index ? "check" : icon} size={22} />
</span>
{vis ? null : <span className="lr-story-cap">{t(key)}</span>}
</span>
</span>
))}
</div>
</div>
);
}
export function StageOverlay({
icon,
message,
@@ -10,24 +10,19 @@ export function StatusIndicator({ status, label }: {
label?: string;
}) {
const { vis, t } = useCopy();
const hint = status.tooltip ?? status.comic;
const hint = (status.tooltip ?? status.comic)!;
const text = label ?? t(status.labelKey);
const wrapped = !vis || Boolean(hint);
const Trigger = wrapped ? "button" : "span";
const indicator = (
<Trigger
type={wrapped ? "button" : undefined}
<button
type="button"
className="lr-status-indicator"
data-tone={status.tone}
data-pulse={status.pulse || undefined}
role={wrapped ? undefined : "img"}
aria-label={text}
>
<Glyph name={status.icon} size={17} />
</Trigger>
</button>
);
return wrapped
? <Tooltip toggleOnClick kind={hint} tone={status.tone} motion={status.pulse ? "progress" : undefined}
text={vis ? undefined : text}>{indicator}</Tooltip>
: indicator;
return <Tooltip toggleOnClick kind={hint} tone={status.tone} motion={status.pulse ? "progress" : "still"}
text={vis ? undefined : text}>{indicator}</Tooltip>;
}
+78 -29
View File
@@ -1,5 +1,5 @@
// Shared tooltip: the same comic in every mode, with a localized caption in
// text modes. Literal names and values can stand alone. Hover and keyboard
// text modes. Truncated literal text needs only its full value. Hover and keyboard
// focus show guidance; help-only controls also toggle it on click/tap.
// Action controls keep their click and use a 500ms touch hold for guidance,
// hiding 1.5s after release and suppressing the trailing synthetic click.
@@ -25,6 +25,7 @@ import {
import { Comic, type ComicKind } from "./Comic";
import { HintComic, isHintKind, type HintKind } from "./hints";
import { isCopyKey, say } from "../../ui/copy";
import { replaySvgAnimations } from "../../ui/animation";
import { comicStyle, getComicPresentation, type ComicTone, type ComicMotion } from "./comic-presentation";
const LONG_PRESS_MS = 500;
@@ -36,6 +37,11 @@ const EDGE_MARGIN = 8;
const FALLBACK_PANEL_HEIGHT = 96;
type Align = "center" | "start" | "end";
// The selector identifies only the visible text, excluding icons and captions.
type OverflowText = { text: string; selector: string };
type TooltipContent =
| { kind: ComicKind | HintKind; text?: string; overflow?: OverflowText }
| { kind?: never; text?: never; overflow: OverflowText };
export function Tooltip({
kind,
@@ -46,10 +52,9 @@ export function Tooltip({
place = "above",
align = "center",
toggleOnClick = false,
overflow,
children,
}: {
kind?: ComicKind | HintKind;
text?: string;
}: TooltipContent & {
tone?: ComicTone;
motion?: ComicMotion;
className?: string;
@@ -76,11 +81,14 @@ export function Tooltip({
const [focusOpen, setFocusOpen] = useState(false);
const [pressOpen, setPressOpen] = useState(false);
const [panelMounted, setPanelMounted] = useState(false);
const [overflowing, setOverflowing] = useState(false);
const [position, setPosition] = useState({ left: 0, top: 0, caret: 0, below: place === "below" });
// True while the current gesture is a touch, so contextmenu can tell a
// long-press from a mouse right-click without reading vendor event fields.
const touchGesture = useRef(false);
const interactionOpen = hoverOpen || focusOpen || pressOpen;
const enabled = kind !== undefined || overflowing;
const caption = [overflowing ? overflow?.text : undefined, text].filter(Boolean).join(" · ") || undefined;
const interactionOpen = enabled && (hoverOpen || focusOpen || pressOpen);
const trigger = isValidElement<{
disabled?: boolean;
"aria-label"?: string;
@@ -100,12 +108,48 @@ export function Tooltip({
rawDisabledTriggerLabel && isCopyKey(rawDisabledTriggerLabel)
? say(rawDisabledTriggerLabel)
: rawDisabledTriggerLabel;
const focusableWrap = disabledTrigger || (overflowing && !kind && trigger?.type !== "button");
const mountPanel = () => {
useLayoutEffect(() => {
const element = overflow ? wrapRef.current?.querySelector<HTMLElement>(overflow.selector) : null;
if (!element || !overflow) {
setOverflowing(false);
return;
}
let disposed = false;
const measure = () => {
// SVG topology labels are shortened before rendering; HTML names use CSS.
if (!disposed) setOverflowing(element.textContent !== overflow.text || element.scrollWidth > element.clientWidth);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(element);
// Font loading can change scrollWidth without changing the clipped box.
void document.fonts?.ready.then(measure);
return () => { disposed = true; observer.disconnect(); };
}, [overflow?.selector, overflow?.text, trigger?.type, trigger?.key]);
useEffect(() => {
if (enabled) return;
setHoverOpen(false);
setFocusOpen(false);
setPressOpen(false);
if (pressTimer.current !== null) window.clearTimeout(pressTimer.current);
pressTimer.current = null;
if (hideTimer.current !== null) window.clearTimeout(hideTimer.current);
hideTimer.current = null;
pressPoint.current = null;
}, [enabled]);
const mountPanel = (replay = false) => {
if (panelUnmountTimer.current !== null) {
window.clearTimeout(panelUnmountTimer.current);
panelUnmountTimer.current = null;
}
// A fresh gesture may reuse a panel still exiting or held by another input
// channel. Its finished animations need the same replay as a fresh mount.
const comic = replay && panelMounted ? tipRef.current?.querySelector("svg") : null;
if (comic) replaySvgAnimations(comic);
setPanelMounted(true);
};
@@ -175,7 +219,7 @@ export function Tooltip({
} else {
tip?.hidePopover?.();
}
}, [panelMounted, text, kind]);
}, [panelMounted, caption, kind]);
useEffect(() => {
if (!interactionOpen || !panelMounted) return;
let frame = 0;
@@ -191,13 +235,17 @@ export function Tooltip({
window.removeEventListener("resize", schedulePick);
window.removeEventListener("scroll", schedulePick, true);
};
}, [panelMounted, interactionOpen, text, kind]);
}, [panelMounted, interactionOpen, caption, kind]);
useEffect(() => {
if (!interactionOpen) return;
// Hover does not move keyboard focus into the trigger.
const dismiss = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
// Close every hint channel, including a hover panel suppressed by a
// focused neighbour. Only visible guidance consumes the outer action.
const style = tipRef.current && getComputedStyle(tipRef.current);
if (style?.visibility === "visible" && style.pointerEvents !== "none") event.preventDefault();
setHoverOpen(false);
setPressOpen(false);
setFocusOpen(false);
@@ -257,13 +305,14 @@ export function Tooltip({
<span
ref={wrapRef}
className={`lr-comic-tip-wrap${className ? ` ${className}` : ""}${disabledTrigger ? " is-disabled-trigger" : ""}${hoverOpen ? " is-hover-open" : ""}${focusOpen ? " is-focus-open" : ""}${pressOpen ? " is-tip-open" : ""}`}
tabIndex={disabledTrigger ? 0 : undefined}
aria-label={disabledTriggerLabel}
aria-describedby={disabledTrigger && text && interactionOpen ? tooltipId : undefined}
tabIndex={focusableWrap ? 0 : undefined}
aria-label={disabledTriggerLabel ?? (focusableWrap ? trigger?.props["aria-label"] ?? overflow?.text : undefined)}
aria-describedby={disabledTrigger && caption && interactionOpen ? tooltipId : undefined}
onPointerEnter={(event) => {
if (!enabled) return;
pickAlign();
if (event.pointerType !== "touch") {
mountPanel();
mountPanel(true);
setHoverOpen(true);
}
}}
@@ -271,9 +320,10 @@ export function Tooltip({
if (event.pointerType !== "touch") setHoverOpen(false);
}}
onFocus={(event) => {
if (!enabled) return;
pickAlign();
if ((event.target as HTMLElement).matches(":focus-visible")) {
mountPanel();
mountPanel(true);
setFocusOpen(true);
}
}}
@@ -284,18 +334,18 @@ export function Tooltip({
}
}}
onPointerDown={(event) => {
touchGesture.current = event.pointerType === "touch";
if (event.pointerType !== "touch") return;
// A suppression token belongs only to the click synthesized for the
// completed long-press. A later touch starts a new, actionable gesture.
longPressed.current = false;
cancelPress();
touchGesture.current = event.pointerType === "touch";
if (!enabled || event.pointerType !== "touch") return;
if (toggleOnClick) return;
pressPoint.current = { x: event.clientX, y: event.clientY };
pressTimer.current = window.setTimeout(() => {
pressTimer.current = null;
longPressed.current = true;
mountPanel();
mountPanel(true);
pickAlign();
setPressOpen(true);
}, LONG_PRESS_MS);
@@ -303,7 +353,7 @@ export function Tooltip({
onPointerUp={() => {
cancelPress();
pressPoint.current = null;
if (longPressed.current) {
if (longPressed.current && enabled) {
if (hideTimer.current !== null) window.clearTimeout(hideTimer.current);
hideTimer.current = window.setTimeout(() => {
hideTimer.current = null;
@@ -336,7 +386,7 @@ export function Tooltip({
// native context menu. Only that gesture is suppressed: a mouse
// right-click (no touch pointerdown) and any menu raised over a text
// field (its paste/select entries are the only way in) stay native.
if (!touchGesture.current) return;
if (!enabled || !touchGesture.current) return;
if (
(event.target as HTMLElement).closest(
"input, textarea, [contenteditable=\"true\"]",
@@ -355,40 +405,39 @@ export function Tooltip({
}
}}
onClick={(event) => {
if (!toggleOnClick || disabledTrigger || tipRef.current?.contains(event.target as Node)) return;
if (!enabled || !toggleOnClick || disabledTrigger || tipRef.current?.contains(event.target as Node)) return;
if (pressOpen) {
setPressOpen(false);
setHoverOpen(false);
setFocusOpen(false);
} else {
mountPanel();
mountPanel(true);
pickAlign();
setPressOpen(true);
}
}}
onKeyDown={(event) => {
if (!enabled) return;
if (event.key !== "Escape" && (event.target as HTMLElement).matches(":focus-visible")) {
mountPanel();
mountPanel(!interactionOpen);
setFocusOpen(true);
}
}}
>
{trigger && (text || toggleOnClick) ? cloneElement(trigger, {
"aria-describedby": [trigger.props["aria-describedby"], interactionOpen ? tooltipId : undefined].filter(Boolean).join(" ") || undefined,
{trigger && (caption || toggleOnClick) ? cloneElement(trigger, {
"aria-describedby": [trigger.props["aria-describedby"], kind && interactionOpen ? tooltipId : undefined].filter(Boolean).join(" ") || undefined,
"aria-expanded": toggleOnClick ? interactionOpen : trigger.props["aria-expanded"],
"aria-controls": toggleOnClick ? tooltipId : trigger.props["aria-controls"],
}) : children}
<span ref={tipRef} id={tooltipId} popover="manual"
data-tone={resolvedTone}
style={{ ...comicStyle(resolvedTone, resolvedMotion), left: position.left, top: position.top, "--tooltip-caret": `${position.caret}px` } as CSSProperties}
className={`lr-comic-tip${text !== undefined ? " is-text" : ""}${kind ? " has-comic" : ""}${placeClass}`} role="tooltip" aria-hidden={!interactionOpen}>
className={`lr-comic-tip${kind ? " has-comic" : ""}${caption !== undefined ? " is-text" : ""}${placeClass}`} role="tooltip" aria-hidden={!interactionOpen}>
{panelMounted ? <>
{kind
? isHintKind(kind)
{kind ? isHintKind(kind)
? <HintComic kind={kind} size={240} tone={resolvedTone} motion={resolvedMotion} />
: <Comic kind={kind} theme="paper" size={240} tone={resolvedTone} motion={resolvedMotion} />
: null}
{text !== undefined ? <span className="lr-comic-tip-caption">{text}</span> : null}
: <Comic kind={kind} theme="paper" size={240} tone={resolvedTone} motion={resolvedMotion} /> : null}
{caption !== undefined ? <span className="lr-comic-tip-caption">{caption}</span> : null}
</> : null}
</span>
</span>
+19 -15
View File
@@ -1,11 +1,12 @@
import type { ConnectionMetrics } from "../../types";
import { formatPacketLossPercent } from "../connection-details";
import { Glyph, type GlyphName } from "../../ui/icons";
import { useCopy, type CopyKey } from "../../ui/copy";
import { Glyph } from "../../ui/icons";
import { useCopy } from "../../ui/copy";
import { PawnSvg } from "./Pawn";
import { participantColor } from "./participant-color";
import type { StatusDescriptor } from "../../ui/media-status";
import { Tooltip } from "./Tooltip";
import { METRIC_PRESENTATION, type MetricLabel } from "./metric-presentation";
export interface ViewerOverviewEntry {
key: string;
@@ -36,10 +37,12 @@ export function ViewerOverview({
unit = "",
): string =>
finite(value ?? null) ? `${value!.toFixed(digits)}${unit}` : unknown;
const heading = (key: CopyKey, icon: GlyphName) => (
<span aria-label={t(key)}>
{vis ? <Glyph name={icon} size={14} /> : t(key)}
</span>
const heading = (key: MetricLabel) => (
<Tooltip toggleOnClick kind={METRIC_PRESENTATION[key].hint} text={vis ? undefined : t(key)}>
<button type="button" aria-label={t(key)} style={{ border: 0, padding: 0, background: "none", color: "inherit", font: "inherit", cursor: "help" }}>
{vis ? <Glyph name={METRIC_PRESENTATION[key].icon} size={14} /> : t(key)}
</button>
</Tooltip>
);
return (
@@ -49,14 +52,14 @@ export function ViewerOverview({
aria-label={t("host.viewerOverview")}
>
<div className="lr-viewer-overview-scroll">
<div className="lr-viewer-overview-head" aria-hidden="true">
<div className="lr-viewer-overview-head">
<span>{vis ? <Glyph name="users" size={14} /> : t("common.viewers")}</span>
<span>{vis ? <Glyph name="network" size={14} /> : t("host.topology")}</span>
{heading("stats.resolution", "expand")}
{heading("stats.fps", "wave")}
{heading("stats.bitrate", "gauge")}
{heading("stats.loss", "drop")}
{heading("stats.rtt", "clock")}
{heading("stats.resolution")}
{heading("stats.fps")}
{heading("stats.bitrate")}
{heading("stats.loss")}
{heading("stats.rtt")}
</div>
<div className="lr-viewer-overview-body">
{entries.map((entry) => {
@@ -64,8 +67,8 @@ export function ViewerOverview({
const hint = entry.status.tooltip ?? entry.status.comic;
const metrics = entry.metrics;
const resolution = metrics?.resolution ?? unknown;
const fps = numberValue(metrics?.framesPerSecond, 1);
const bitrate = numberValue(metrics?.bitrateKbps, 0);
const fps = numberValue(metrics?.framesPerSecond, 1, " fps");
const bitrate = numberValue(metrics?.bitrateKbps, 0, " kbps");
const loss = metrics
? formatPacketLossPercent(metrics.packetLossPercent, unknown)
: unknown;
@@ -134,7 +137,8 @@ export function ViewerOverview({
kind={hint}
tone={entry.status.tone}
motion={entry.status.pulse ? "progress" : undefined}
text={vis ? hint ? undefined : entry.name : `${entry.name} · ${statusLabel}`}
text={vis ? undefined : statusLabel}
overflow={{ text: entry.name, selector: ".lr-viewer-overview-person-copy > b" }}
align="start"
>
{row}
@@ -20,6 +20,27 @@ const STATES: Record<ComicKind, ComicPresentation> = {
"signal-connecting": CONNECTING,
"signal-recovering": RECOVERING,
"signal-offline": { tone: "off", motion: "still" },
"signal-connected": { tone: "live", motion: "still" },
"transport-connected": { tone: "live", motion: "still" },
"signal-failed": FAILED,
"media-playing": { tone: "live", motion: "still" },
"media-ready": { tone: "live", motion: "still" },
"share-live": { tone: "live", motion: "still" },
"share-ended": { tone: "off", motion: "still" },
"room-closed": { tone: "off", motion: "still" },
"room-code-invalid": FAILED,
"page-refresh": NEUTRAL,
"site-access": { tone: "off", motion: "still" },
"source-switching": CONNECTING,
"source-starting": CONNECTING,
"preview-paused": LIMITED,
"update-available": { tone: "off", motion: "still" },
"debug-start": NEUTRAL,
"debug-export-failed": FAILED,
"copy-failed": FAILED,
"source-failed": FAILED,
"settings-failed": FAILED,
"name-invalid": FAILED,
"tap-to-play": { tone: "busy", motion: "demo" },
"host-paused": LIMITED,
recovering: RECOVERING,
@@ -40,6 +61,9 @@ export const COMIC_KINDS = Object.keys(STATES) as ComicKind[];
const PRESENTATIONS: Partial<Record<ComicKind | HintKind, ComicPresentation>> = {
...STATES,
"hint-admission-code": { tone: "off", motion: "still" },
"hint-admission-password": { tone: "off", motion: "still" },
"hint-admission-invite": { tone: "off", motion: "still" },
"hint-route-p2p-required": LIMITED,
"hint-nat-unavailable": LIMITED,
"hint-volume-basic": LIMITED,
@@ -0,0 +1,49 @@
import { BrowserWindow, Frame, INK_STAGE, InviteLink, MiniTv, Pawn, SKY, TV_SCREEN, YOU, rmBlock } from "../Comic";
import type { AdmissionHintKind, ComicTheme, HintScene } from "../../../ui/visual-kinds";
// The credential admits this person; the room stays open throughout.
// At the final pose the same person has reached the room's open doorway.
function AdmissionHint({ theme, credential }: { theme: ComicTheme; credential: "code" | "password" | "invite" }) {
const password = credential === "password";
return <>
<style>{`
.vls-admission-person{animation:vlsAdmissionApproach var(--comic-duration,3.2s) ease-in-out 1 both}
@keyframes vlsAdmissionApproach{0%,8%{transform:translateX(-32px)}36%,100%{transform:none}}
${rmBlock(["vls-admission-person"], [[".vls-admission-person", "transform:none"]], false)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} result />
<rect x={228} y={16} width={72} height={65} rx={6} fill="var(--wall-2)" stroke="var(--ink)" strokeWidth={2.5} />
<path d="M228 16l-12 7v65l12-7Z" fill="var(--paper)" stroke="var(--ink)" strokeWidth={2.5} strokeLinejoin="round" />
<MiniTv x={266} y={41} w={25} h={20} />
<path d="m270 55 6-6 5 4 6-7" fill="none" stroke={SKY} strokeWidth={1.5} />
<g className="vls-admission-person">
<Pawn x={245} yb={82} s={16} eyes gaze={2} />
</g>
<Pawn x={132} yb={82} s={16} eyes gaze={-2} />
<circle cx={113} cy={65} r={3.5} fill={YOU} />
<BrowserWindow x={20} y={18} w={87} h={62}>
{credential === "invite" ? <g className="vls-admission-invite">
<rect x={28} y={38} width={71} height={27} rx={5} fill="var(--wall-2)" />
<InviteLink x={31} y={39} size={25} />
<path d="M62 51h28" fill="none" stroke="var(--ink)" strokeWidth={2} strokeLinecap="round" />
</g> : <>
<g className="vls-admission-code">
<rect x={28} y={password ? 32 : 38} width={71} height={password ? 21 : 28} rx={5} fill={TV_SCREEN} />
{[36, 51, 66, 81].map(x => <rect key={x} x={x} y={password ? 37 : 46} width={9} height={10} rx={2}
fill="none" stroke={INK_STAGE} strokeWidth={2} />)}
</g>
{password ? <g className="vls-admission-password">
<rect x={28} y={58} width={71} height={15} rx={4} fill="var(--wall-2)" stroke="var(--ink)" strokeWidth={1.5} />
{[41, 56, 71, 86].map(cx => <circle key={cx} cx={cx} cy={65.5} r={2.5} fill="var(--ink)" />)}
</g> : null}
</>}
</BrowserWindow>
</>;
}
export const ADMISSION_SCENES: Record<AdmissionHintKind, HintScene> = {
"hint-admission-code": ({ theme }) => <AdmissionHint theme={theme} credential="code" />,
"hint-admission-password": ({ theme }) => <AdmissionHint theme={theme} credential="password" />,
"hint-admission-invite": ({ theme }) => <AdmissionHint theme={theme} credential="invite" />,
};
+97 -13
View File
@@ -1,4 +1,4 @@
import { FAINT, Frame, LIVE, MiniTv, Pawn, SKY, STAR_GOLD, rmBlock } from "../Comic";
import { BrowserWindow, FAINT, Frame, LIVE, MiniTv, Pawn, SKY, STAR_GOLD, rmBlock } from "../Comic";
import type { ComicTheme } from "../Comic";
import type { ControlHintKind, HintScene } from "../../../ui/visual-kinds";
@@ -24,39 +24,67 @@ ${rmBlock(["vls-fold-body"], [[".vls-fold-body", "transform:scaleY(0);opacity:0"
</>
);
function PasswordHint({ theme, reveal }: { theme: ComicTheme; reveal: boolean }) {
function PasswordHint({ theme, action }: { theme: ComicTheme; action: "show" | "hide" | "clear" }) {
const clear = action === "clear";
return <>
<style>{`
.vls-password-new{animation:vlsPasswordNew var(--comic-duration,3.2s) ease-out var(--comic-repeat,1) both}
.vls-password-new,.vls-password-clear{animation:vlsPasswordNew var(--comic-duration,3.2s) ease-out var(--comic-repeat,1) both}
.vls-password-clear{animation-name:vlsPasswordClear}
@keyframes vlsPasswordNew{0%,14%{opacity:0}35%,100%{opacity:1}}
${rmBlock(["vls-password-new"], [[".vls-password-new", "opacity:1"]])}
@keyframes vlsPasswordClear{0%,14%{opacity:1}35%,100%{opacity:0}}
${rmBlock(["vls-password-new", "vls-password-clear"], [[".vls-password-new", "opacity:1"], [".vls-password-clear", "opacity:0"]])}
`}</style>
{[4, 164].map((x, index) => {
const visible = index ? reveal : !reveal;
const visible = !clear && (index ? action === "show" : action === "hide");
return <g key={x}>
<Frame x={x} w={152} theme={theme} result={index === 1} />
<rect x={x + 22} y={35} width={108} height={31} rx={7} fill="var(--wall-2)" stroke="var(--ink)" strokeWidth={2} />
<g className={index ? "vls-password-new" : undefined} fill="var(--ink)">
{visible ? <text x={x + 76} y={57} textAnchor="middle" fontSize={21} fontFamily="monospace">1234</text>
<g className={index ? clear ? "vls-password-clear" : "vls-password-new" : undefined}
opacity={clear && index ? 0 : undefined} fill="var(--ink)">
{visible ? <text x={x + 76} y={57} textAnchor="middle" fontSize={21} fontFamily="var(--mono)" fontWeight={700}>1234</text>
: [49, 67, 85, 103].map((cx) => <circle key={cx} cx={x + cx} cy={51} r={3.5} />)}
</g>
<path d={`M${x + 63} 23 q13 -14 26 0 q-13 14 -26 0`} fill="none" stroke="var(--ink)" strokeWidth={2} />
{visible ? <circle cx={x + 76} cy={23} r={3} fill={LIVE} />
: <path d={`M${x + 66} 14 l20 18`} stroke="var(--ink)" strokeWidth={2.5} strokeLinecap="round" />}
{clear ? <path d={`M${x + 66} 24 l12 -12 10 10 -8 8 h-8 Z m7 -2 9 8 m-10 0 h18`}
fill="none" stroke="var(--ink)" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" /> : <>
<path d={`M${x + 63} 23 q13 -14 26 0 q-13 14 -26 0`} fill="none" stroke="var(--ink)" strokeWidth={2} />
{visible ? <circle cx={x + 76} cy={23} r={3} fill={LIVE} />
: <path d={`M${x + 66} 14 l20 18`} stroke="var(--ink)" strokeWidth={2.5} strokeLinecap="round" />}
</>}
</g>;
})}
</>;
}
// A captured audio track is a source fact, not a locked setting or delivery proof.
const SourceAudioHint: HintScene = ({ theme }) => <>
<style>{`
.vls-source-audio-note{animation:vlsSourceAudioNote var(--comic-duration,3.2s) ease-out 1 both}
@keyframes vlsSourceAudioNote{0%,8%{transform:translateY(5px)}32%,100%{transform:none}}
${rmBlock(["vls-source-audio-note"], [[".vls-source-audio-note", "transform:none"]], false)}
`}</style>
<Frame x={4} w={312} theme={theme} result />
<Pawn x={66} yb={81} s={13} eyes host gaze={2} />
<MiniTv x={112} y={24} w={98} h={55} />
<path d="M125 61l18-19 15 11 18-17 18 20" fill="none" stroke={SKY} strokeWidth={3} />
<g className="vls-source-audio-note">
<path d="M238 57V30l23-5v26M238 35l23-5" fill="none" stroke="var(--ink)" strokeWidth={3} strokeLinecap="round" />
<ellipse cx={233} cy={58} rx={6} ry={4} fill="var(--ink)" />
<ellipse cx={256} cy={52} rx={6} ry={4} fill="var(--ink)" />
</g>
</>;
// Source audio goes to the other viewer. This is deliberately not local mute.
function ShareAudioHint({ theme, enabled, locked = false }: { theme: ComicTheme; enabled: boolean; locked?: boolean }) {
return <>
<style>{`
.vls-share-audio-change{animation:vlsShareAudioChange var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-share-audio-off{animation-name:vlsShareAudioOff}
.vls-share-audio-lock{transform-box:fill-box;transform-origin:center;animation:vlsShareAudioLock var(--comic-duration,3.2s) ease-in-out 1 both}
@keyframes vlsShareAudioChange{0%,12%{opacity:0}36%,100%{opacity:1}}
@keyframes vlsShareAudioOff{0%,12%{opacity:1}36%,100%{opacity:0}}
@keyframes vlsShareAudioLock{0%,8%,34%,100%{transform:none}16%{transform:translateX(-3px) rotate(-12deg)}25%{transform:translateX(2px) rotate(8deg)}}
${rmBlock(["vls-share-audio-change"], [[".vls-share-audio-change", "opacity:1"], [".vls-share-audio-off", "opacity:0"]])}
${rmBlock(["vls-share-audio-lock"], [[".vls-share-audio-lock", "transform:none"]], false)}
`}</style>
{[4, 164].map((x, index) => {
const audible = locked || index ? enabled : !enabled;
@@ -72,7 +100,7 @@ ${rmBlock(["vls-share-audio-change"], [[".vls-share-audio-change", "opacity:1"],
<ellipse cx={x + 94} cy={38} rx={4} ry={3} fill={STAR_GOLD} />
<ellipse cx={x + 107} cy={35} rx={4} ry={3} fill={STAR_GOLD} />
</g>
{locked && index ? <g stroke="var(--ink)" strokeWidth={1.5}>
{locked && index ? <g className="vls-share-audio-lock" stroke="var(--ink)" strokeWidth={1.5}>
<path d={`M${x + 87} 77 v-3 a3 3 0 0 1 6 0 v3`} fill="none" />
<rect x={x + 85} y={77} width={10} height={8} rx={2} fill={STAR_GOLD} />
</g> : null}
@@ -81,10 +109,66 @@ ${rmBlock(["vls-share-audio-change"], [[".vls-share-audio-change", "opacity:1"],
</>;
}
const RefreshSourcesHint: HintScene = ({ theme }) => <>
<style>{`
.vls-refresh-path{stroke-dasharray:1;animation:vlsRefreshPath var(--comic-duration,3.2s) ease-out var(--comic-repeat,1) both}
.vls-refresh-source{animation:vlsRefreshSource var(--comic-duration,3.2s) ease-out var(--comic-repeat,1) both}
@keyframes vlsRefreshPath{0%,8%{stroke-dashoffset:1}24%,100%{stroke-dashoffset:0}}
@keyframes vlsRefreshSource{0%,24%{opacity:0;transform:translateY(5px)}42%,100%{opacity:1;transform:none}}
${rmBlock(["vls-refresh-path", "vls-refresh-source"], [[".vls-refresh-path", "stroke-dashoffset:0"], [".vls-refresh-source", "opacity:1;transform:none"]])}
`}</style>
{[4, 164].map((x, index) => <g key={x}>
<Frame x={x} w={152} theme={theme} result={index === 1} />
<path d={`M${x + 123} 26 a8 8 0 1 1 9 -10 l-5 1 m5 -1 v-5`} pathLength={1}
className={index ? "vls-refresh-path" : undefined}
fill="none" stroke="var(--ink)" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" />
{[22, 82].map((offset, tile) => <g key={offset} className={index && tile ? "vls-refresh-source" : undefined}>
<rect x={x + offset} y={37} width={48} height={35} rx={5}
fill="var(--paper)" stroke={index || !tile ? "var(--ink)" : FAINT} strokeWidth={2}
strokeDasharray={!index && tile ? "3 3" : undefined} />
{index || !tile ? <>
<path d={`M${x + offset} 47 h48`} stroke="var(--ink)" strokeWidth={2} />
<path d={`M${x + offset + 9} 63 l10 -9 9 6 10 -6`} fill="none" stroke={tile ? SKY : LIVE} strokeWidth={2.5} />
</> : null}
</g>)}
</g>)}
</>;
function SourceListHint({ theme, empty = false }: { theme: ComicTheme; empty?: boolean }) {
return <>
<style>{`
.vls-source-cursor{animation:vlsSourceCursor var(--comic-duration,3.2s) ease-in-out 1 both}
.vls-source-look{transform-box:fill-box;transform-origin:50% 100%;animation:vlsSourceLook var(--comic-duration,3.2s) ease-in-out 1 both}
@keyframes vlsSourceCursor{0%,8%{transform:translate(35px,9px)}30%,100%{transform:none}}
@keyframes vlsSourceLook{0%,8%,42%,100%{transform:none}22%{transform:rotate(7deg)}}
${rmBlock(["vls-source-cursor", "vls-source-look"], [[".vls-source-cursor,.vls-source-look", "transform:none"]], false)}
`}</style>
<Frame x={4} w={312} theme={theme} result />
<Pawn x={53} yb={79} s={15} eyes gaze={2} className={empty ? "vls-source-look" : undefined} />
<BrowserWindow x={94} y={14} w={172} h={67}>
{empty ? <>
<rect x={124} y={36} width={112} height={30} rx={4} fill="none" stroke={FAINT} strokeWidth={2} strokeDasharray="4 4" />
<path d="M172 51h16" stroke="var(--ink)" strokeWidth={2.5} strokeLinecap="round" />
</> : <>
{[111, 158, 205].map((x) => <g key={x}>
<rect x={x} y={35} width={37} height={29} rx={4} fill="var(--paper)" stroke="var(--ink)" strokeWidth={2} />
<path d={`M${x + 6} 56l7-9 7 6 9-11`} fill="none" stroke={SKY} strokeWidth={2} />
</g>)}
<path className="vls-source-cursor" d="M179 52v18l5-5 5 8 4-2-5-8 8-1Z" fill="var(--paper)" stroke="var(--ink)" strokeWidth={1.8} />
</>}
</BrowserWindow>
</>;
}
export const CONTROL_SCENES: Record<ControlHintKind, HintScene> = {
"hint-collapse": CollapseHint,
"hint-password-show": (props) => <PasswordHint {...props} reveal />,
"hint-password-hide": (props) => <PasswordHint {...props} reveal={false} />,
"hint-refresh-sources": RefreshSourcesHint,
"hint-source-picker": (props) => <SourceListHint {...props} />,
"hint-no-sources": (props) => <SourceListHint {...props} empty />,
"hint-password-show": (props) => <PasswordHint {...props} action="show" />,
"hint-password-hide": (props) => <PasswordHint {...props} action="hide" />,
"hint-password-remove": (props) => <PasswordHint {...props} action="clear" />,
"hint-source-audio": SourceAudioHint,
"hint-share-audio": (props) => <ShareAudioHint {...props} enabled />,
"hint-stop-audio": (props) => <ShareAudioHint {...props} enabled={false} />,
"hint-share-audio-fixed": (props) => <ShareAudioHint {...props} enabled locked />,
+19 -4
View File
@@ -7,13 +7,15 @@
// by the tsx preview harness, which uses the classic JSX runtime.
import { createElement, memo, type CSSProperties } from "react";
import type { HintKind, HintScene } from "../../../ui/visual-kinds";
import type { HintKind, HintScene, MetricHintKind } from "../../../ui/visual-kinds";
import { SET1_SCENES } from "./set1";
import { SET2_SCENES } from "./set2";
import { SET3_SCENES } from "./set3";
import { SET4_SCENES } from "./set4";
import { PLAYBACK_SCENES } from "./playback";
import { CONTROL_SCENES } from "./controls";
import { METRIC_SCENES } from "./metrics";
import { ADMISSION_SCENES } from "./admission";
import { comicStyle, getComicPresentation, type ComicTone, type ComicMotion } from "../comic-presentation";
export type { HintKind, HintScene };
@@ -24,19 +26,26 @@ export const HINT_KINDS: readonly HintKind[] = [
"hint-pause",
"hint-resume",
"hint-switch-source",
"hint-refresh-sources",
"hint-source-picker",
"hint-no-sources",
"hint-reconnect",
"hint-copy-code",
"hint-shuffle-code",
"hint-copy-invite",
"hint-invite-link",
"hint-client-link",
"hint-rotate-invite",
"hint-revoke-invite",
"hint-policy-open",
"hint-policy-private",
"hint-password",
"hint-admission-code",
"hint-admission-password",
"hint-admission-invite",
"hint-quality",
"hint-audio-quality",
"hint-degrade-pref",
"hint-prefer-resolution",
"hint-prefer-framerate",
"hint-codec",
"hint-advanced",
"hint-details",
@@ -50,6 +59,8 @@ export const HINT_KINDS: readonly HintKind[] = [
"hint-collapse",
"hint-password-show",
"hint-password-hide",
"hint-password-remove",
"hint-source-audio",
"hint-share-audio",
"hint-stop-audio",
"hint-share-audio-fixed",
@@ -80,6 +91,7 @@ export const HINT_KINDS: readonly HintKind[] = [
"hint-pip",
"hint-pip-exit",
"hint-pip-unavailable",
...Object.keys(METRIC_SCENES) as MetricHintKind[],
];
export const HINT_SCENES: Record<HintKind, HintScene> = {
@@ -89,6 +101,8 @@ export const HINT_SCENES: Record<HintKind, HintScene> = {
...SET4_SCENES,
...PLAYBACK_SCENES,
...CONTROL_SCENES,
...METRIC_SCENES,
...ADMISSION_SCENES,
};
const HINT_KIND_SET: ReadonlySet<string> = new Set(HINT_KINDS);
@@ -121,6 +135,7 @@ export const HintComic = memo(function HintComic({
return createElement(
"svg",
{
key: kind,
viewBox: "0 0 320 96",
"data-comic-tone": resolvedTone,
"data-comic-motion": resolvedMotion,
@@ -129,6 +144,6 @@ export const HintComic = memo(function HintComic({
focusable: false,
xmlns: "http://www.w3.org/2000/svg",
},
HINT_SCENES[kind]({ theme: "paper" }),
createElement("g", { className: "lr-comic-content" }, HINT_SCENES[kind]({ theme: "paper" })),
);
});
@@ -0,0 +1,167 @@
import type { ReactNode } from "react";
import type { HintScene, MetricHintKind } from "../../../ui/visual-kinds";
import { Frame, INK_STAGE, MiniTv, Pawn, SKY, rmBlock } from "../Comic";
const INK = "var(--ink)";
const PAPER = "var(--paper)";
type Metric = MetricHintKind extends `hint-metric-${infer Name}` ? Name : never;
function MetricMotion() {
return <style>{`
.vls-metric-flow{animation:vlsMetricFlow var(--comic-duration,3.2s) ease-out both}
.vls-metric-loss{animation:vlsMetricLoss var(--comic-duration,3.2s) ease-out both}
.vls-metric-sampling{stroke-dasharray:1;animation:vlsMetricSampling var(--comic-duration,3.2s) ease-out both}
.vls-metric-measure{transform-box:fill-box;transform-origin:center;animation:vlsMetricMeasure var(--comic-duration,3.2s) ease-out both}
.vls-metric-hand{animation:vlsMetricHand var(--comic-duration,3.2s) ease-out both}
.vls-metric-outbound{stroke-dasharray:1;animation:vlsMetricOutbound var(--comic-duration,3.2s) ease-out both}
.vls-metric-return{stroke-dasharray:1;animation:vlsMetricReturn var(--comic-duration,3.2s) ease-out both}
.vls-metric-drop{animation:vlsMetricDrop var(--comic-duration,3.2s) ease-in-out both}
.vls-metric-lag{animation:vlsMetricLag var(--comic-duration,3.2s) ease-out both}
@keyframes vlsMetricFlow{0%,8%{transform:translateX(-12px)}36%,100%{transform:none}}
@keyframes vlsMetricLoss{0%,10%{transform:translateY(-6px);opacity:0}38%,100%{transform:none;opacity:1}}
@keyframes vlsMetricSampling{0%,8%{stroke-dashoffset:1}42%,100%{stroke-dashoffset:0}}
@keyframes vlsMetricMeasure{0%,8%{transform:scale(.78)}38%,100%{transform:none}}
@keyframes vlsMetricHand{0%,8%{transform:rotate(-120deg)}46%,100%{transform:none}}
@keyframes vlsMetricOutbound{0%,8%{stroke-dashoffset:1}24%,100%{stroke-dashoffset:0}}
@keyframes vlsMetricReturn{0%,24%{stroke-dashoffset:1}49%,100%{stroke-dashoffset:0}}
@keyframes vlsMetricDrop{0%,12%{transform:translateY(-14px)}38%,100%{transform:none}}
@keyframes vlsMetricLag{0%,20%{transform:translateX(-57px)}48%,100%{transform:none}}
${rmBlock(["vls-metric-flow", "vls-metric-loss", "vls-metric-sampling", "vls-metric-measure", "vls-metric-hand", "vls-metric-outbound", "vls-metric-return", "vls-metric-drop", "vls-metric-lag"], [
[".vls-metric-flow,.vls-metric-loss,.vls-metric-measure,.vls-metric-hand,.vls-metric-drop,.vls-metric-lag", "transform:none;opacity:1"],
[".vls-metric-sampling,.vls-metric-outbound,.vls-metric-return", "stroke-dashoffset:0"],
], false)}
`}</style>;
}
function Clock({ x, y = 26, unit = "ms", moving = true }: { x: number; y?: number; unit?: string; moving?: boolean }) {
return <g stroke={INK} strokeWidth={2} fill="none" strokeLinecap="round">
<circle cx={x} cy={y} r={12} /><path className={moving ? "vls-metric-hand" : undefined} style={{ transformOrigin: `${x}px ${y}px` }} d={`M${x} ${y - 7}v7l5 3`} />
<text x={x + 18} y={y + 4} fill={INK} stroke="none" fontSize={12}>{unit}</text>
</g>;
}
function Arrow({ x, y = 52, width = 28, className }: { x: number; y?: number; width?: number; className?: string }) {
return <path className={className} pathLength={1} d={`M${x} ${y}h${width}m-5-4 5 4-5 4`} stroke={INK} strokeWidth={2} fill="none" strokeLinecap="round" strokeLinejoin="round" />;
}
function Screen({ x, y = 30, crossed = false }: { x: number; y?: number; crossed?: boolean }) {
return <g stroke={INK} strokeWidth={2} fill={PAPER} strokeLinecap="round">
<rect x={x} y={y} width={30} height={24} rx={3} />
{crossed ? <path className="vls-metric-loss" d={`m${x + 8} ${y + 6} 14 12m0-12-14 12`} />
: <path d={`m${x + 4} ${y + 19} 8-9 6 5 5-4 4 8`} fill="none" />}
</g>;
}
function Speaker({ x, y = 36 }: { x: number; y?: number }) {
return <g stroke={INK} strokeWidth={2} fill="none" strokeLinecap="round" strokeLinejoin="round">
<path d={`M${x} ${y + 8}h8l12-8v32l-12-8h-8Zm26 1q9 7 0 14`} />
</g>;
}
function Chip({ x, y = 32 }: { x: number; y?: number }) {
return <g stroke={INK} strokeWidth={2} fill={PAPER}>
<rect x={x} y={y} width={36} height={32} rx={5} />
<path d={`M${x + 11} ${y - 5}v5m14-5v5m-14 32v5m14-5v5M${x - 5} ${y + 10}h5m-5 12h5m36-12h5m-5 12h5`} />
</g>;
}
function Packets({ x, y = 45, loss = false, capacity = false, moving = true }: { x: number; y?: number; loss?: boolean; capacity?: boolean; moving?: boolean }) {
return <g stroke={INK} strokeWidth={2} fill={PAPER}>
<g className={moving ? "vls-metric-flow" : undefined}>
{[0, 1, 2].map(index => <rect key={index} x={x + index * 22} y={y} width={14} height={12} rx={2}
strokeDasharray={loss && index === 1 ? "2 3" : undefined} opacity={loss && index === 1 ? .4 : 1} />)}
{loss ? <path className="vls-metric-loss" d={`m${x + 25} ${y + 18} 8 8m0-8-8 8`} strokeLinecap="round" /> : null}
</g>
{capacity ? <rect x={x - 5} y={y - 15} width={68} height={38} rx={4} fill="none" strokeDasharray="4 4" /> : null}
</g>;
}
function MetricPicture({ metric }: { metric: Metric }): ReactNode {
if (metric === "resolution" || metric === "capture") return <>
<MiniTv x={56} y={19} w={108} h={54} />
<path className="vls-metric-measure" d="M56 79h108m-108-4v8m108-8v8M182 19v54m-4-54h8m-8 54h8" stroke={INK} strokeWidth={2} fill="none" />
<text x={109} y={90} textAnchor="middle" fill={INK} fontSize={11}>px</text>
<text x={193} y={50} fill={INK} fontSize={12}>px</text>
{metric === "capture" ? <><Arrow x={222} /><g className="vls-metric-flow"><Screen x={265} /></g></> : <Pawn x={268} yb={78} s={13} color={SKY} eyes />}
</>;
if (metric === "fps" || metric === "input-fps") return <>
<g className="vls-metric-flow">{[0, 1, 2].map(index => <Screen key={index} x={32 + index * 40} y={39} />)}</g>
<path d="M32 76h110m-110-4v8m110-8v8" stroke={INK} strokeWidth={2} />
<text x={88} y={28} fill={INK} textAnchor="middle" fontSize={14}>1 s</text>
<Arrow x={165} width={35} />
{metric === "input-fps" ? <Chip x={238} /> : <MiniTv x={226} y={29} w={60} h={39} />}
</>;
if (["bitrate", "outgoing", "audio-bitrate", "loss", "audio-loss"].includes(metric)) return <>
{metric.startsWith("audio") ? <Speaker x={28} /> : <Screen x={28} y={39} />}
<Arrow x={78} /><Packets x={123} loss={metric.endsWith("loss")} capacity={metric === "outgoing"} />
<Arrow x={201} /><Pawn x={272} yb={75} s={13} color={SKY} eyes />
{!metric.endsWith("loss") ? <text x={153} y={82} textAnchor="middle" fill={INK} fontSize={13}>kb/s</text> : null}
</>;
if (metric === "jitter" || metric === "audio-jitter") return <>
{metric === "audio-jitter" ? <Speaker x={28} /> : <Screen x={28} y={39} />}
<Arrow x={80} />
<path className="vls-metric-sampling" pathLength={1} d="M126 70h162m-154-24v20m19-20v20m46-20v20m19-20v20m54-20v20" stroke={INK} strokeWidth={2.5} strokeLinecap="round" />
<Clock x={202} y={24} />
</>;
if (metric === "dropped") return <>
<Screen x={30} y={38} /><g className="vls-metric-drop"><Screen x={72} y={52} crossed /></g><Screen x={114} y={38} />
<Arrow x={165} /><MiniTv x={225} y={29} w={65} h={40} />
</>;
if (metric === "rtt") return <>
<Pawn x={55} yb={79} s={16} eyes /><Pawn x={265} yb={79} s={16} color={SKY} eyes />
<Arrow x={87} y={40} width={145} className="vls-metric-outbound" /><g transform="translate(320 105) rotate(180)"><Arrow x={87} y={40} width={145} className="vls-metric-return" /></g>
<Clock x={160} y={21} moving={false} />
</>;
if (["encoder", "encode-time", "decode-time", "quality"].includes(metric)) return <>
{metric === "decode-time" ? <Packets x={21} /> : <Screen x={37} y={39} />}
<Arrow x={94} /><Chip x={144} y={40} /><Arrow x={202} />
{metric === "decode-time" ? <MiniTv x={244} y={33} w={50} h={34} /> : <Packets x={241} />}
{metric.endsWith("time") ? <Clock x={162} y={19} /> : <path d="M147 49h30m-30 8h30m-20-10v5m10 2v6" stroke={INK} strokeWidth={2} />}
</>;
if (metric === "freezes" || metric === "freeze-time") return <>
<MiniTv x={25} y={27} w={68} h={44} /><MiniTv x={134} y={27} w={68} h={44} />
<path d="M42 58l14-17 18 17Z" fill={SKY} /><path className="vls-metric-flow" d="M151 58l14-17 18 17Z" fill={SKY} />
<Arrow x={99} width={25} /><path d="M164 36v11m7-11v11" stroke={INK_STAGE} strokeWidth={3} />
{metric === "freeze-time" ? <Clock x={264} y={43} /> : <text x={264} y={58} textAnchor="middle" fill={INK} fontSize={23}>#</text>}
</>;
if (metric === "playout") return <>
<Screen x={24} y={16} /><Speaker x={24} y={49} />
<path d="M89 29h193M89 73h193M165 45h57m-57-4v8m57-8v8" stroke={INK} strokeWidth={2} />
<path className="vls-metric-flow" d="M165 22v14" stroke={INK} strokeWidth={2} />
<path className="vls-metric-lag" d="M222 66v14" stroke={INK} strokeWidth={2} />
<text x={194} y={61} fill={INK} textAnchor="middle" fontSize={12}>Δ ms</text>
</>;
if (metric === "video-buffer" || metric === "audio-buffer") return <>
<Packets x={23} moving={false} /><Arrow x={96} />
<path d="M133 37v36h69V37" stroke={INK} strokeWidth={2} fill="none" /><Packets x={140} y={53} />
<Clock x={166} y={20} /><Arrow x={211} />
{metric === "audio-buffer" ? <Speaker x={264} /> : <MiniTv x={255} y={35} w={48} h={31} />}
</>;
return <>
<Speaker x={24} />
<path d="M76 53h12l6-16 9 31 8-15h18m55 0h13l6-16 9 31 8-15h69" stroke={INK} strokeWidth={2} fill="none" />
<rect x={135} y={35} width={44} height={35} rx={4} stroke={INK} strokeDasharray="3 3" fill="none" />
<path className="vls-metric-loss" d="M139 53h8l5-12 7 23 6-11h8" stroke={INK} strokeWidth={2} fill="none" />
<path d="M138 77h38m-38-4v8m38-8v8" stroke={INK} strokeWidth={2} />
<text x={158} y={29} fill={INK} fontSize={18} textAnchor="middle">{metric === "concealment-rate" ? "%" : "#"}</text>
</>;
}
const scene = (metric: Metric): HintScene => ({ theme }) => <>
<MetricMotion /><Frame x={4} w={312} theme={theme} result /><MetricPicture metric={metric} />
</>;
export const METRIC_SCENES: Record<MetricHintKind, HintScene> = {
"hint-metric-resolution": scene("resolution"), "hint-metric-fps": scene("fps"),
"hint-metric-bitrate": scene("bitrate"), "hint-metric-loss": scene("loss"),
"hint-metric-rtt": scene("rtt"), "hint-metric-outgoing": scene("outgoing"),
"hint-metric-quality": scene("quality"), "hint-metric-capture": scene("capture"),
"hint-metric-input-fps": scene("input-fps"), "hint-metric-encoder": scene("encoder"),
"hint-metric-encode-time": scene("encode-time"), "hint-metric-jitter": scene("jitter"),
"hint-metric-dropped": scene("dropped"), "hint-metric-decode-time": scene("decode-time"),
"hint-metric-freezes": scene("freezes"), "hint-metric-freeze-time": scene("freeze-time"),
"hint-metric-audio-bitrate": scene("audio-bitrate"), "hint-metric-audio-loss": scene("audio-loss"),
"hint-metric-audio-jitter": scene("audio-jitter"), "hint-metric-playout": scene("playout"),
"hint-metric-video-buffer": scene("video-buffer"), "hint-metric-audio-buffer": scene("audio-buffer"),
"hint-metric-concealment-rate": scene("concealment-rate"), "hint-metric-concealments": scene("concealments"),
};
@@ -25,6 +25,7 @@ function PlaybackMotion() {
.vls-pb-popout,.vls-pb-popin{transform-box:fill-box;transform-origin:center}
.vls-pb-popout{animation:vlsPbPopout var(--comic-duration,3.2s) ease-out var(--comic-repeat,1) both}
.vls-pb-popin{animation:vlsPbPopin var(--comic-duration,3.2s) ease-out var(--comic-repeat,1) both}
.vls-pb-unavailable{animation:vlsPbUnavailable var(--comic-duration,3.2s) ease-in-out 1 both}
@keyframes vlsPbCue{0%,8%,34%,100%{transform:scale(1)}16%{transform:scale(.86)}25%{transform:scale(1.08)}}
@keyframes vlsPbPlay{0%,18%{transform:translateX(-12px)}40%,100%{transform:none}}
@keyframes vlsPbFreeze{0%{transform:translateX(-12px)}18%,100%{transform:none}}
@@ -38,14 +39,19 @@ function PlaybackMotion() {
@keyframes vlsPbChromeIn{0%,8%{opacity:0}30%,100%{opacity:1}}
@keyframes vlsPbPopout{0%,8%{transform:translate(-28px,-14px) scale(1.4)}34%,100%{transform:none}}
@keyframes vlsPbPopin{0%,8%{transform:translate(32px,16px) scale(.65)}34%,100%{transform:none}}
@keyframes vlsPbUnavailable{0%,8%,36%,100%{transform:none}16%{transform:translateX(-4px)}26%{transform:translateX(3px)}}
${rmBlock(
["vls-pb-cue", "vls-pb-play", "vls-pb-freeze", "vls-pb-waves .vls-pb-wave",
"vls-pb-waves.is-muting .vls-pb-wave", "vls-pb-cross", "vls-pb-listen",
"vls-pb-flat", "vls-pb-expand", "vls-pb-contract", "vls-pb-chrome-out", "vls-pb-chrome-in", "vls-pb-popout", "vls-pb-popin"],
[[".vls-pb-wave,.vls-pb-flat", "stroke-dashoffset:0;opacity:1"],
"vls-pb-waves.is-muting .vls-pb-wave", "vls-pb-cross",
"vls-pb-expand", "vls-pb-contract", "vls-pb-chrome-out", "vls-pb-chrome-in", "vls-pb-popout", "vls-pb-popin"],
[[".vls-pb-wave", "stroke-dashoffset:0;opacity:1"],
[".vls-pb-waves.is-muting .vls-pb-wave,.vls-pb-chrome-out", "opacity:0"],
[".vls-pb-cross,.vls-pb-chrome-in", "opacity:1"]],
)}
${rmBlock(["vls-pb-listen", "vls-pb-flat", "vls-pb-unavailable"], [
[".vls-pb-listen,.vls-pb-unavailable", "transform:none"],
[".vls-pb-flat", "stroke-dashoffset:0;opacity:1"],
], false)}
`}</style>;
}
@@ -126,7 +132,7 @@ const NoAudioHint: HintScene = ({ theme }) => <>
<rect x={35} y={79} width={90} height={7} rx={3.5} fill="none" stroke={FAINT} strokeWidth={1.5} strokeDasharray="3 3" />
<path className="vls-pb-flat" pathLength={1} d="M45 82.5h70" stroke={FAINT} strokeWidth={1.5} strokeLinecap="round" />
<LocalSpeaker x={160} silent="absent" />
<Pawn x={283} yb={79} s={11} color={YOU} eyes />
<Pawn x={283} yb={79} s={11} color={YOU} eyes className="vls-pb-listen" />
</>;
function PlaybackViewport({ x, fullscreen, animate = false }: { x: number; fullscreen: boolean; animate?: boolean }) {
@@ -151,7 +157,7 @@ function FullscreenHint({ theme, exit = false, unavailable = false }: Parameters
<Frame x={164} w={152} theme={theme} result />
<PlaybackViewport x={0} fullscreen={exit} />
<PlaybackViewport x={160} fullscreen={!unavailable && !exit} animate={!unavailable} />
{unavailable ? <path d="M276 28h14v14m0-14-18 18m-2-16 18 18"
{unavailable ? <path className="vls-pb-unavailable" d="M276 28h14v14m0-14-18 18m-2-16 18 18"
fill="none" stroke="var(--comic-tone)" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" /> : null}
</>;
}
@@ -168,7 +174,7 @@ function PictureHint({ theme, exit = false, unavailable = false }: Parameters<Hi
<Frame x={x + 4} w={152} theme={theme} result={index === 1} />
<BrowserWindow x={x + 14} y={14} w={112} h={65} />
<path d={`M${x + 28} 37h65m-65 9h45m-45 9h31`} stroke={FAINT} strokeWidth={2} opacity={.35} />
<g className={index && !unavailable ? floating ? "vls-pb-popout" : "vls-pb-popin" : undefined}>
<g className={index ? unavailable ? "vls-pb-unavailable" : floating ? "vls-pb-popout" : "vls-pb-popin" : undefined}>
<MiniTv x={x + (floating ? 92 : 38)} y={floating ? 51 : 31}
w={floating ? 54 : 70} h={floating ? 31 : 41} />
<path d={floating ? `M${x + 113} 58l10 6-10 6Z` : `M${x + 65} 42l14 8-14 8Z`} fill={SKY} />
+19 -12
View File
@@ -146,13 +146,16 @@ const ScenePause: HintScene = ({ theme }) => (
.vls-pz-flick{animation:vlsPzFlick var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-pz-hold{animation:vlsPzHold var(--comic-duration,3.2s) ease-out var(--comic-repeat,1) both}
.vls-pz-bars{transform-box:fill-box;transform-origin:center;animation:vlsPzBars var(--comic-duration,3.2s) cubic-bezier(.3,1.5,.5,1) var(--comic-repeat,1) both}
:where(svg[data-comic-motion="still"]) .vls-pz-bars{animation-name:vlsPzRest}
@keyframes vlsPzFlick{0%{opacity:.5}10%{opacity:.9}20%{opacity:.2}32%{opacity:.85}44%,100%{opacity:.5}}
@keyframes vlsPzHold{0%{opacity:0}12%,100%{opacity:1}}
@keyframes vlsPzBars{0%,5%{opacity:0;transform:scale(1.45)}16%,100%{opacity:1;transform:scale(1)}}
@keyframes vlsPzRest{0%,8%{transform:scale(1.15)}30%,100%{transform:none}}
${rmBlock(
["vls-pz-flick", "vls-pz-hold", "vls-pz-bars"],
[[".vls-pz-flick", "opacity:.5"], [".vls-pz-hold,.vls-pz-bars", "opacity:1;transform:none"]],
["vls-pz-flick", "vls-pz-hold"],
[[".vls-pz-flick", "opacity:.5"], [".vls-pz-hold", "opacity:1;transform:none"]],
)}
${rmBlock(["vls-pz-bars"], [[".vls-pz-bars", "opacity:1;transform:none"]], false)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} result />
@@ -184,13 +187,16 @@ const SceneResume: HintScene = ({ theme }) => (
.vls-rs-play{transform-box:fill-box;transform-origin:center;animation:vlsRsPlay var(--comic-duration,3.2s) cubic-bezier(.3,1.5,.5,1) var(--comic-repeat,1) both}
.vls-rs-led{animation:vlsRsLed var(--comic-duration,3.2s) ease-out var(--comic-repeat,1) both}
.vls-rs-flick{animation:vlsRsFlick var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
:where(svg[data-comic-motion="still"]) .vls-rs-play{animation-name:vlsRsRest}
@keyframes vlsRsPlay{0%{transform:scale(.55);opacity:.5}14%{transform:scale(1.18);opacity:1}22%,100%{transform:scale(1);opacity:1}}
@keyframes vlsRsLed{0%{opacity:.2}10%,100%{opacity:1}}
@keyframes vlsRsFlick{0%,12%{opacity:0}20%{opacity:.9}30%{opacity:.25}40%{opacity:.85}54%,100%{opacity:.5}}
@keyframes vlsRsRest{0%,8%{transform:translateX(-3px)}30%,100%{transform:none}}
${rmBlock(
["vls-rs-play", "vls-rs-led", "vls-rs-flick"],
[[".vls-rs-play,.vls-rs-led", "opacity:1;transform:none"], [".vls-rs-flick", "opacity:.5"]],
["vls-rs-led", "vls-rs-flick"],
[[".vls-rs-led", "opacity:1;transform:none"], [".vls-rs-flick", "opacity:.5"]],
)}
${rmBlock(["vls-rs-play"], [[".vls-rs-play", "opacity:1;transform:none"]], false)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} result />
@@ -217,9 +223,12 @@ const SceneSwitchSource: HintScene = ({ theme }) => (
<style>{`
.vls-sw-d1{animation:vlsSwD1 var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-sw-d2{animation:vlsSwD2 var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
:where(svg[data-comic-motion="still"]) .vls-sw-selected{animation:vlsSwSelected var(--comic-duration,3.2s) ease-out 1 both}
@keyframes vlsSwD1{0%{transform:translate(0,0);opacity:0}8%{opacity:1}22%{transform:translate(11px,-6px)}36%{transform:translate(22px,0);opacity:1}46%,100%{transform:translate(22px,0);opacity:0}}
@keyframes vlsSwD2{0%{transform:translate(0,0);opacity:0}8%{opacity:1}22%{transform:translate(-11px,6px)}36%{transform:translate(-22px,0);opacity:1}46%,100%{transform:translate(-22px,0);opacity:0}}
@keyframes vlsSwSelected{0%,8%{transform:translateY(-4px)}30%,100%{transform:none}}
${rmBlock(["vls-sw-d1", "vls-sw-d2"], [[".vls-sw-d1,.vls-sw-d2", "opacity:0;transform:none"]])}
${rmBlock(["vls-sw-selected"], [[".vls-sw-selected", "transform:none"]], false)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} result />
@@ -228,7 +237,8 @@ ${rmBlock(["vls-sw-d1", "vls-sw-d2"], [[".vls-sw-d1,.vls-sw-d2", "opacity:0;tran
<SourceWindow x={187} y={28} />
<SourceWindow x={247} y={28} alternate />
<g stroke={LIVE} strokeWidth={2.5} strokeLinecap="round" fill="none">
<path d="M41 70l5 5 10-10 M261 70l5 5 10-10" />
<path d="M41 70l5 5 10-10" />
<path className="vls-sw-selected" d="M261 70l5 5 10-10" />
</g>
<g stroke={SKY} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none">
<path d="M229 29 C236 16 244 16 251 29 M244.5 26.5 L251 29 L249 22.5" />
@@ -239,23 +249,20 @@ ${rmBlock(["vls-sw-d1", "vls-sw-d2"], [[".vls-sw-d1,.vls-sw-d2", "opacity:0;tran
</>
);
/* hint-reconnect: [Plug out of Socket, small Spark at the gap] → [Plug seats
with a wiggle, LIVE dot pops]. Same plug language as recovering. */
/* Reconnect asks the transport to try again; this prospective action cannot
claim a successful connection or a delivered frame. */
const SceneReconnect: HintScene = ({ theme }) => (
<>
<style>{`
.vls-rj-spark{animation:vlsRjSpark var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-rj-plug{transform-box:fill-box;transform-origin:50% 100%;animation:vlsRjPlug var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-rj-led{transform-box:fill-box;transform-origin:center;animation:vlsRjLed var(--comic-duration,3.2s) cubic-bezier(.3,1.5,.5,1) var(--comic-repeat,1) both}
@keyframes vlsRjSpark{0%,100%{opacity:.55}15%{opacity:1}}
@keyframes vlsRjPlug{0%{transform:translate(-2px,-8px) rotate(-6deg)}10%{transform:translate(-1px,-3px) rotate(4deg)}20%{transform:translate(0,-1px) rotate(-2deg)}28%,100%{transform:none}}
@keyframes vlsRjLed{0%,24%{opacity:0;transform:scale(.4)}32%{opacity:1;transform:scale(1.3)}40%,100%{opacity:1;transform:scale(1)}}
${rmBlock(
["vls-rj-spark", "vls-rj-plug", "vls-rj-led"],
["vls-rj-spark", "vls-rj-plug"],
[
[".vls-rj-spark", "opacity:1"],
[".vls-rj-plug", "transform:none"],
[".vls-rj-led", "opacity:1;transform:none"],
],
)}
`}</style>
@@ -268,7 +275,7 @@ ${rmBlock(
<g className="vls-rj-plug">
<Plug x={260} y={36} />
</g>
<circle className="vls-rj-led" cx={284} cy={38} r={3.5} fill={LIVE} />
<path d="M281 24a8 8 0 1 1-7 12m0 0v-5m0 5h5" fill="none" stroke="var(--ink)" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
</>
);
+84 -340
View File
@@ -1,26 +1,33 @@
// Hint set 2 — room & invite: code copy/shuffle, invite copy/rotate/revoke, policy open/private, password.
// Hint set 2 — room & invite: code copy/shuffle, invite copy/rotate/revoke, password.
// Scenes follow ../Comic.tsx conventions: 320x96 canvas, 2-panel
// before→after idiom (Frame x={4} w={152} + Frame x={164} w={152}), vls-
// prefixed keyframes in an inline <style>, rmBlock for reduced motion.
// Paper idioms: var(--ink)/var(--wall-2)/var(--paper) carry the drawing;
// room codes are always 4 abstract digit slots, never real digits.
import {
Door,
BrowserWindow,
FAINT,
Frame,
INK_STAGE,
LIVE,
InviteLink,
Pawn,
RedX,
SKY,
Spark,
Star,
STAR_GOLD,
TV_SCREEN,
rmBlock,
} from "../Comic";
import type { HintScene, Set2Kind } from "../../../ui/visual-kinds";
// A settled card or held link rests in place; it does not repeat its operation.
function RoomResultMotion() {
return <style>{`
:where(svg[data-comic-motion="still"]) .vls-room-result{transform-box:fill-box;transform-origin:center;animation:vlsRoomResult var(--comic-duration,3.2s) ease-out 1 both}
@keyframes vlsRoomResult{0%,8%{transform:translateY(-4px) rotate(-4deg)}30%,100%{transform:none}}
${rmBlock(["vls-room-result"], [[".vls-room-result", "transform:none"]], false)}
`}</style>;
}
/* LCD code card: pale body, dark screen, 4 abstract digit slots at +12/+27/
+42/+57 inside the 78x46 body. */
const SLOT_DX = [12, 27, 42, 57] as const;
@@ -72,67 +79,12 @@ function LcdSlots({
return className ? <g className={className}>{slots}</g> : slots;
}
/** One chain link: a stadium ring, optionally tilted (static transform). */
function LinkRing({
x,
y,
w,
h,
tilt = 0,
}: {
x: number;
y: number;
w: number;
h: number;
tilt?: number;
}) {
const ring = (
<rect
x={x}
y={y}
width={w}
height={h}
rx={h / 2}
fill="var(--paper)"
stroke="var(--ink)"
strokeWidth={2.5}
/>
);
return tilt ? <g transform={`rotate(${tilt} ${x + w / 2} ${y + h / 2})`}>{ring}</g> : ring;
}
/** Open doorway: warm lit frame + leaf swung open on the left hinge. */
function OpenDoorway({ x, y }: { x: number; y: number }) {
return (
<>
<rect
x={x}
y={y}
width={32}
height={56}
rx={5}
fill={STAR_GOLD}
opacity={0.3}
stroke="var(--ink)"
strokeWidth={2.5}
/>
<path
d={`M${x} ${y} L${x - 12} ${y + 6} L${x - 12} ${y + 62} L${x} ${y + 56} Z`}
fill="var(--wall-2)"
stroke="var(--ink)"
strokeWidth={2.5}
strokeLinejoin="round"
/>
<circle cx={x - 8} cy={y + 32} r={1.8} fill="var(--ink)" />
</>
);
}
/* ------------------------------------------------------------------ */
/* hint-copy-code: tap the code card → a second copy + star. */
/* ------------------------------------------------------------------ */
const SceneCopyCode: HintScene = ({ theme }) => (
<>
<RoomResultMotion />
<style>{`
.vls-copy-pawn{transform-box:fill-box;transform-origin:50% 100%;animation:vlsCopyLean var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-copy-ring{transform-box:fill-box;transform-origin:center;animation:vlsCopyRing var(--comic-duration,3.2s) ease-out var(--comic-repeat,1) both}
@@ -166,10 +118,10 @@ ${rmBlock(
/>
<Pawn x={38} yb={76} s={9} eyes className="vls-copy-pawn" />
<LcdBase x={210} y={18} />
<g className="vls-copy-front">
<g className="vls-room-result"><g className="vls-copy-front">
<LcdBase x={190} y={32} />
<LcdSlots x={190} y={32} />
</g>
</g></g>
<Star x={292} y={22} r={7} className="vls-copy-star" baseOpacity={0} />
</>
);
@@ -179,6 +131,7 @@ ${rmBlock(
/* ------------------------------------------------------------------ */
const SceneShuffleCode: HintScene = ({ theme }) => (
<>
<RoomResultMotion />
<style>{`
.vls-shuf-slots{opacity:.45}
.vls-shuf-roll{animation:vlsShufRoll var(--comic-duration,3.2s) linear var(--comic-repeat,1) both}
@@ -197,6 +150,7 @@ ${rmBlock(
<Frame x={164} w={152} theme={theme} result />
<LcdBase x={41} y={25} />
<LcdSlots x={41} y={25} />
<g className="vls-room-result">
<LcdBase x={201} y={25} />
<LcdSlots x={201} y={25} className="vls-shuf-slots" />
<g className="vls-shuf-roll" fill={INK_STAGE} opacity={0.9}>
@@ -208,72 +162,69 @@ ${rmBlock(
</g>
))}
</g>
</g>
<Spark x={288} y={30} className="vls-shuf-spark" />
</>
);
/* ------------------------------------------------------------------ */
/* hint-copy-invite: pawn balancing a link → link arcs to a friend. */
/* Copy writes a link to this device's clipboard; it sends no invitation. */
/* ------------------------------------------------------------------ */
const SceneCopyInvite: HintScene = ({ theme }) => (
<>
<RoomResultMotion />
<style>{`
.vls-cinv-eyes{transform-box:fill-box;transform-origin:center;animation:vlsCinvBlink var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-cinv-fly{animation:vlsCinvFly var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-cinv-arc{animation:vlsCinvArc var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-cinv-star{transform-box:fill-box;transform-origin:center;animation:vlsCinvStar var(--comic-duration,3.2s) cubic-bezier(.3,1.5,.5,1) var(--comic-repeat,1) both}
@keyframes vlsCinvBlink{0%,60%,68%,100%{transform:scaleY(1)}64%{transform:scaleY(.12)}}
@keyframes vlsCinvFly{0%,6%{transform:translate(-92px,0);opacity:0}10%{opacity:1}32%{transform:translate(-46px,-20px)}50%{transform:translate(0,0)}56%{transform:translate(0,-3px)}62%,100%{transform:translate(0,0)}}
@keyframes vlsCinvArc{0%,10%{opacity:0}20%{opacity:.7}55%{opacity:.7}72%,100%{opacity:0}}
@keyframes vlsCinvStar{0%,50%{opacity:0;transform:scale(0)}58%{opacity:1;transform:scale(1.25)}66%,100%{opacity:1;transform:scale(1)}}
${rmBlock(
["vls-cinv-eyes", "vls-cinv-fly", "vls-cinv-arc", "vls-cinv-star"],
[
[".vls-cinv-fly", "transform:none;opacity:1"],
[".vls-cinv-arc", "opacity:.35"],
[".vls-cinv-star", "opacity:1"],
],
)}
.vls-cinv-copy{animation:vlsCinvCopy var(--comic-duration,3.2s) ease-out var(--comic-repeat,1) both}
@keyframes vlsCinvCopy{0%,8%{transform:translate(-12px,-13px);opacity:.25}36%,100%{transform:none;opacity:1}}
${rmBlock(["vls-cinv-copy"], [[".vls-cinv-copy", "transform:none;opacity:1"]])}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} result />
<Pawn x={62} yb={76} s={10} eyes host eyeClassName="vls-cinv-eyes" />
<LinkRing x={50} y={27.5} w={24} h={13} tilt={-12} />
<path
className="vls-cinv-arc"
d="M196 50 Q242 14 288 50"
stroke={FAINT}
strokeWidth={2}
strokeDasharray="4 4"
fill="none"
opacity={0}
/>
<Pawn x={196} yb={76} s={8.5} eyes host />
<Pawn x={288} yb={76} s={8.5} eyes color={SKY} />
<g className="vls-cinv-fly" opacity={0}>
<LinkRing x={278} y={31.5} w={20} h={11} tilt={-12} />
</g>
<Star x={288} y={16} r={6} className="vls-cinv-star" baseOpacity={0} />
<BrowserWindow x={24} y={21} w={104} h={54}>
<rect x={34} y={39} width={84} height={24} rx={4} fill="var(--wall-2)" stroke={FAINT} strokeWidth={1.5} />
<InviteLink x={44} y={37} size={28} />
<path d="M93 51h15" stroke={FAINT} strokeWidth={2} strokeLinecap="round" />
</BrowserWindow>
<rect x={205} y={21} width={76} height={60} rx={7} fill="var(--wall-2)" stroke="var(--ink)" strokeWidth={2.5} />
<rect x={227} y={15} width={32} height={12} rx={4} fill="var(--paper)" stroke="var(--ink)" strokeWidth={2.5} />
<g className="vls-room-result"><g className="vls-cinv-copy">
<InviteLink x={226} y={33} size={34} />
</g></g>
</>
);
// The App's public-link choice reuses the established invite animation and
// adds one small globe mark so the destination is clear without text.
const HintClientLink: HintScene = ({ theme }) => (
<>
<SceneCopyInvite theme={theme} />
<g fill="none" stroke={LIVE} strokeWidth={1.8}>
<circle cx={180} cy={18} r={7} />
<path d="M173 18h14M180 11c3 3 3 11 0 14M180 11c-3 3-3 11 0 14" />
</g>
</>
);
// A link identifies its room; the App mode also exposes a public web entry.
// Neither diagram claims clipboard access, delivery to a friend or media relay.
function InviteLinkHint({ theme, publicEntry = false }: Parameters<HintScene>[0] & { publicEntry?: boolean }) {
return <>
<style>{`
.vls-link-address{animation:vlsLinkAddress var(--comic-duration,3.2s) ease-out 1 both}
@keyframes vlsLinkAddress{0%,8%{transform:translateY(-7px)}34%,100%{transform:none}}
${rmBlock(["vls-link-address"], [[".vls-link-address", "transform:none"]], false)}
`}</style>
<Frame x={4} w={312} theme={theme} result />
<BrowserWindow x={24} y={22} w={123} h={53}>
<g className="vls-link-address">
<InviteLink x={40} y={39} size={30} />
<path d="M89 50h44m-44 10h32" stroke={FAINT} strokeWidth={2} strokeLinecap="round" />
</g>
</BrowserWindow>
<path d="M157 49h39" stroke={FAINT} strokeWidth={2} strokeDasharray="3 4" />
{publicEntry ? <g stroke="var(--ink)" strokeWidth={2.5} fill="none">
<circle cx={252} cy={49} r={27} />
<ellipse cx={252} cy={49} rx={12} ry={27} />
<path d="M225 49h54m-49-14h44m-44 28h44" />
</g> : <><LcdBase x={210} y={26} /><LcdSlots x={210} y={26} /></>}
</>;
}
const HintClientLink: HintScene = (props) => <InviteLinkHint {...props} publicEntry />;
/* ------------------------------------------------------------------ */
/* hint-rotate-invite: a link → the link spun fresh + spark. */
/* ------------------------------------------------------------------ */
const SceneRotateInvite: HintScene = ({ theme }) => (
<>
<RoomResultMotion />
<style>{`
.vls-rinv-ring{transform-box:view-box;transform-origin:240px 48px;animation:vlsRinvSpin var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-rinv-spark{transform-box:fill-box;transform-origin:center;animation:vlsRinvSpark var(--comic-duration,3.2s) ease-out var(--comic-repeat,1) both}
@@ -286,8 +237,8 @@ ${rmBlock(
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} result />
<LinkRing x={65} y={40} w={30} h={16} tilt={-15} />
<LinkRing x={225} y={40} w={30} h={16} tilt={-15} />
<InviteLink x={60} y={28} size={40} />
<g className="vls-room-result"><InviteLink x={222} y={30} size={36} /></g>
<g
className="vls-rinv-ring"
stroke={SKY}
@@ -302,250 +253,44 @@ ${rmBlock(
</>
);
/* ------------------------------------------------------------------ */
/* hint-revoke-invite: intact chain → snapped, ends droop, red-X. */
/* ------------------------------------------------------------------ */
// Revocation disables the same invitation URL; the slash remains visible.
const SceneRevokeInvite: HintScene = ({ theme }) => (
<>
<style>{`
.vls-rev-l{transform-box:view-box;transform-origin:223px 52.5px;animation:vlsRevL var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-rev-r{transform-box:view-box;transform-origin:257px 52.5px;animation:vlsRevR var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-rev-x{transform-box:fill-box;transform-origin:center;animation:vlsRevX var(--comic-duration,3.2s) ease-out var(--comic-repeat,1) both}
@keyframes vlsRevL{0%,10%{transform:translate(9px,-4.5px) rotate(-18deg)}20%{transform:translate(-2px,1px) rotate(5deg)}30%,100%{transform:translate(0,0) rotate(0)}}
@keyframes vlsRevR{0%,10%{transform:translate(-9px,-4.5px) rotate(18deg)}20%{transform:translate(2px,1px) rotate(-5deg)}30%,100%{transform:translate(0,0) rotate(0)}}
@keyframes vlsRevX{0%,30%{opacity:0;transform:scale(1.6) rotate(8deg)}38%,100%{opacity:1;transform:scale(1) rotate(8deg)}}
${rmBlock(
["vls-rev-l", "vls-rev-r", "vls-rev-x"],
[[".vls-rev-x", "opacity:1;transform:scale(1) rotate(8deg)"]],
)}
.vls-rev-link{transform-box:fill-box;transform-origin:center;animation:vlsRevLink var(--comic-duration,3.2s) ease-out 1 both}
@keyframes vlsRevLink{0%,8%{transform:rotate(-9deg)}30%,100%{transform:none}}
${rmBlock(["vls-rev-link"], [[".vls-rev-link", "transform:none"]], false)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} result />
<LinkRing x={56} y={41} w={28} h={14} />
<LinkRing x={76} y={41} w={28} h={14} />
<g className="vls-rev-l">
<g transform="rotate(18 223 52.5)">
<rect
x={210}
y={46}
width={26}
height={13}
rx={6.5}
fill="var(--paper)"
stroke="var(--ink)"
strokeWidth={2.5}
/>
</g>
</g>
<g className="vls-rev-r">
<g transform="rotate(-18 257 52.5)">
<rect
x={244}
y={46}
width={26}
height={13}
rx={6.5}
fill="var(--paper)"
stroke="var(--ink)"
strokeWidth={2.5}
/>
</g>
</g>
<RedX cx={240} cy={38} arm={5} className="vls-rev-x" />
<InviteLink x={59} y={27} size={42} />
<InviteLink x={219} y={27} size={42} className="vls-rev-link" />
<path d="m217 24 47 48" stroke="var(--paper)" strokeWidth={8} strokeLinecap="round" />
<path d="m217 24 47 48" stroke="var(--ink)" strokeWidth={3} strokeLinecap="round" />
</>
);
/* ------------------------------------------------------------------ */
/* hint-policy-open: shut door → door wide open, two pawns hop in. */
/* ------------------------------------------------------------------ */
const ScenePolicyOpen: HintScene = ({ theme }) => (
<>
<style>{`
.vls-open-p1{transform-box:fill-box;transform-origin:50% 100%;animation:vlsOpenHop var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-open-p2{transform-box:fill-box;transform-origin:50% 100%;animation:vlsOpenHop var(--comic-duration,3.2s) ease-in-out .2s var(--comic-repeat,1) both}
@keyframes vlsOpenHop{0%,6%{transform:translateY(0)}14%{transform:translateY(-5px)}22%{transform:translateY(0)}28%{transform:translateY(-3px)}34%,100%{transform:translateY(0)}}
${rmBlock(["vls-open-p1", "vls-open-p2"], [])}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} result />
<Door x={64} y={20} />
<OpenDoorway x={224} y={18} />
<Pawn x={238} yb={74} s={6.5} eyes className="vls-open-p1" />
<Pawn x={206} yb={78} s={5.5} eyes color={SKY} className="vls-open-p2" />
</>
);
/* ------------------------------------------------------------------ */
/* hint-policy-private: open door → shut + locked, one pawn waits with */
/* its little invite card. */
/* ------------------------------------------------------------------ */
const ScenePolicyPrivate: HintScene = ({ theme }) => (
<>
<style>{`
.vls-priv-eyes{transform-box:fill-box;transform-origin:center;animation:vlsPrivBlink var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-priv-card{transform-box:fill-box;transform-origin:center;animation:vlsPrivCard var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-priv-door{transform-box:fill-box;transform-origin:0% 50%;animation:vlsPrivDoor var(--comic-duration,3.2s) cubic-bezier(.3,.8,.35,1) var(--comic-repeat,1) both}
.vls-priv-lock{transform-box:fill-box;transform-origin:center;animation:vlsPrivLock var(--comic-duration,3.2s) cubic-bezier(.3,1.4,.45,1) var(--comic-repeat,1) both}
.vls-priv-click{transform-box:fill-box;transform-origin:center;animation:vlsPrivClick var(--comic-duration,3.2s) ease-out var(--comic-repeat,1) both}
@keyframes vlsPrivBlink{0%,52%,60%,100%{transform:scaleY(1)}56%{transform:scaleY(.12)}}
@keyframes vlsPrivCard{0%,8%{transform:translateY(2px) rotate(-3deg)}18%{transform:translateY(-3px) rotate(4deg)}28%,100%{transform:none}}
@keyframes vlsPrivDoor{0%,10%{transform:scaleX(.18);opacity:.45}28%,100%{transform:scaleX(1);opacity:1}}
@keyframes vlsPrivLock{0%,24%{transform:translateY(-6px);opacity:0}34%{transform:translateY(1px);opacity:1}40%,100%{transform:none;opacity:1}}
@keyframes vlsPrivClick{0%,32%{opacity:0;transform:scale(.45)}38%{opacity:1;transform:scale(1.12)}48%,100%{opacity:0;transform:scale(1)}}
${rmBlock(
["vls-priv-eyes", "vls-priv-card", "vls-priv-door", "vls-priv-lock", "vls-priv-click"],
[
[".vls-priv-card,.vls-priv-door,.vls-priv-lock", "transform:none;opacity:1"],
[".vls-priv-click", "opacity:.65;transform:none"],
],
)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} result />
<OpenDoorway x={64} y={18} />
<Pawn x={204} yb={76} s={8} eyes eyeClassName="vls-priv-eyes" />
<g className="vls-priv-card">
<g transform="rotate(-10 202 44)">
<rect
x={195}
y={39}
width={14}
height={10}
rx={2}
fill="var(--paper)"
stroke="var(--ink)"
strokeWidth={1.5}
/>
<path
d="M197.5 42.5 h4 M197.5 45.5 h6"
stroke="var(--ink)"
strokeWidth={1.2}
strokeLinecap="round"
/>
</g>
</g>
<Door x={224} y={18} className="vls-priv-door" />
<g className="vls-priv-lock">
<path
d="M235 38 v-5 a5.5 5.5 0 0 1 11 0 v5"
stroke="var(--ink)"
strokeWidth={2.5}
fill="none"
strokeLinecap="round"
/>
<rect
x={232}
y={38}
width={16}
height={13}
rx={3}
fill={STAR_GOLD}
stroke="var(--ink)"
strokeWidth={2}
/>
<circle cx={240} cy={44} r={1.8} fill="var(--ink)" />
</g>
<Spark x={253} y={35} className="vls-priv-click" />
</>
);
/* ------------------------------------------------------------------ */
/* hint-password: key approaches the lock → shackle up, door ajar. */
/* ------------------------------------------------------------------ */
/* Password entry/configuration uses the same field; no door opens until
the separate admission owner has accepted a credential. */
const ScenePassword: HintScene = ({ theme }) => (
<>
<RoomResultMotion />
<style>{`
.vls-pass-key{animation:vlsPassKey var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-pass-lines{animation:vlsPassLines var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-pass-shackle{transform-box:view-box;transform-origin:190px 44px;animation:vlsPassShackle var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-pass-glow{animation:vlsPassGlow var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
@keyframes vlsPassKey{0%{transform:translateX(0);opacity:0}6%{opacity:1}30%,100%{transform:translateX(26px);opacity:1}}
@keyframes vlsPassLines{0%,4%{opacity:0}10%{opacity:.9}24%{opacity:.9}32%,100%{opacity:0}}
@keyframes vlsPassShackle{0%,8%{transform:rotate(34deg)}20%{transform:rotate(-6deg)}28%,100%{transform:rotate(0)}}
@keyframes vlsPassGlow{0%,30%{opacity:.3}42%{opacity:.65}55%,100%{opacity:.3}}
${rmBlock(
["vls-pass-key", "vls-pass-lines", "vls-pass-shackle", "vls-pass-glow"],
[
[".vls-pass-key", "transform:translateX(26px);opacity:1"],
[".vls-pass-lines", "opacity:0"],
[".vls-pass-glow", "opacity:.45"],
],
)}
.vls-pass-input{animation:vlsPassInput var(--comic-duration,3.2s) ease-out var(--comic-repeat,1) both}
@keyframes vlsPassInput{0%,8%{transform:translateX(-12px);opacity:.2}34%,100%{transform:none;opacity:1}}
${rmBlock(["vls-pass-input"], [[".vls-pass-input", "transform:none;opacity:1"]])}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} result />
<g className="vls-pass-lines" opacity={0}>
<path d="M42 42 l-7 -2 M42 52 l-7 2" stroke={FAINT} strokeWidth={2} strokeLinecap="round" />
</g>
<g className="vls-pass-key" opacity={0}>
<circle cx={52} cy={47} r={5} fill="var(--paper)" stroke="var(--ink)" strokeWidth={2.5} />
<path d="M57 47 h14 M66 47 v4 M70 47 v4" stroke="var(--ink)" strokeWidth={2.5} strokeLinecap="round" />
</g>
<path
d="M92 40 v-5 a6 6 0 0 1 12 0 v5"
stroke="var(--ink)"
strokeWidth={2.5}
fill="none"
strokeLinecap="round"
/>
<rect
x={88}
y={40}
width={18}
height={14}
rx={3}
fill={STAR_GOLD}
stroke="var(--ink)"
strokeWidth={2}
/>
<circle cx={97} cy={47} r={1.8} fill="var(--ink)" />
<rect
className="vls-pass-glow"
x={230}
y={16}
width={36}
height={58}
rx={5}
fill={SKY}
opacity={0.3}
/>
<rect
x={230}
y={16}
width={36}
height={58}
rx={5}
fill="none"
stroke="var(--ink)"
strokeWidth={2.5}
/>
<path
d="M230 16 L260 20 L260 70 L230 74 Z"
fill="var(--wall-2)"
stroke="var(--ink)"
strokeWidth={2.5}
strokeLinejoin="round"
/>
<circle cx={254} cy={46} r={1.8} fill="var(--ink)" />
<rect
x={186}
y={44}
width={18}
height={14}
rx={3}
fill={STAR_GOLD}
stroke="var(--ink)"
strokeWidth={2}
/>
<circle cx={195} cy={51} r={1.8} fill="var(--ink)" />
<path
className="vls-pass-shackle"
d="M190 44 v-5 a6 6 0 0 1 11.8 -2.2"
stroke="var(--ink)"
strokeWidth={2.5}
fill="none"
strokeLinecap="round"
/>
{[24, 184].map((x, index) => <BrowserWindow key={x} x={x} y={19} w={112} h={61}>
<circle cx={x + 18} cy={38} r={4} fill="none" stroke="var(--ink)" strokeWidth={2} />
<path d={`M${x + 22} 38h14m-4 0v4`} stroke="var(--ink)" strokeWidth={2} fill="none" />
<rect x={x + 12} y={49} width={88} height={22} rx={5} fill="var(--paper)" stroke="var(--ink)" strokeWidth={2} />
{index ? <g className="vls-room-result"><g className="vls-pass-input" fill="var(--ink)">
{[23, 36, 49, 62, 75, 88].map(offset => <circle key={offset} cx={x + offset} cy={60} r={2.5} />)}
</g></g> : <path d={`M${x + 23} 55v10`} stroke="var(--ink)" strokeWidth={2} />}
</BrowserWindow>)}
</>
);
@@ -553,10 +298,9 @@ export const SET2_SCENES: Record<Set2Kind, HintScene> = {
"hint-copy-code": SceneCopyCode,
"hint-shuffle-code": SceneShuffleCode,
"hint-copy-invite": SceneCopyInvite,
"hint-invite-link": (props) => <InviteLinkHint {...props} />,
"hint-client-link": HintClientLink,
"hint-rotate-invite": SceneRotateInvite,
"hint-revoke-invite": SceneRevokeInvite,
"hint-policy-open": ScenePolicyOpen,
"hint-policy-private": ScenePolicyPrivate,
"hint-password": ScenePassword,
};
+100 -210
View File
@@ -4,222 +4,99 @@
// prefixed keyframes in an inline <style>, rmBlock for reduced motion.
import {
FAINT,
Frame,
LINE,
LIVE,
MINT,
MiniTv,
Pawn,
YOU,
SKY,
STAR_GOLD,
Star,
TV_BODY,
TV_EDGE,
Frame,
MiniTv,
Pawn,
rmBlock,
} from "../Comic";
import type { HintScene, Set3Kind } from "../../../ui/visual-kinds";
/** hint-quality: soft TV (few fat scanlines, squinting pawn) → sharp TV (many
* thin crisp lines, one light sweep, star pop). Demonstrates once, then holds. */
const hintQuality: HintScene = ({ theme }) => (
<>
<style>{`
.vls-vq-soft{animation:vlsVqSoft var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-vq-sweep{animation:vlsVqSweep var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-vq-star{transform-box:fill-box;transform-origin:center;animation:vlsVqStar var(--comic-duration,3.2s) cubic-bezier(.3,1.5,.5,1) var(--comic-repeat,1) both}
@keyframes vlsVqSoft{0%,48%,100%{opacity:.4}12%{opacity:.58}24%{opacity:.34}36%{opacity:.52}}
@keyframes vlsVqSweep{0%,22%{transform:translateX(0);opacity:0}30%{opacity:.3}48%,100%{transform:translateX(49px);opacity:0}}
@keyframes vlsVqStar{0%,55%{opacity:0;transform:scale(0)}66%{opacity:1;transform:scale(1.25)}74%,100%{opacity:1;transform:scale(1)}}
${rmBlock(
["vls-vq-soft", "vls-vq-sweep", "vls-vq-star"],
[
[".vls-vq-soft", "opacity:.4"],
[".vls-vq-sweep", "opacity:0"],
[".vls-vq-star", "opacity:1;transform:none"],
],
)}
// Settings explain a choice; they do not predict delivered quality.
const hintQuality: HintScene = ({ theme }) => <>
<style>{`
.vls-quality-choice{animation:vlsQualityChoice var(--comic-duration,3.2s) ease-in-out 1 both}
.vls-quality-size{stroke-dasharray:1;animation:vlsQualitySize var(--comic-duration,3.2s) ease-out 1 both}
@keyframes vlsQualityChoice{0%,8%{transform:translateX(-43px)}30%,100%{transform:none}}
@keyframes vlsQualitySize{0%,18%{stroke-dashoffset:1}42%,100%{stroke-dashoffset:0}}
${rmBlock(["vls-quality-choice"], [[".vls-quality-choice", "transform:none"]])}
${rmBlock(["vls-quality-size"], [[".vls-quality-size", "stroke-dashoffset:0"]], false)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} result />
{/* BEFORE: pawn squints at a soft picture (3 fat faint scanlines) */}
<Pawn x={26} yb={80} s={8} eyes="closed" host gaze={2} />
<MiniTv x={52} y={22} w={76} h={46} />
<g className="vls-vq-soft" fill={LINE} opacity={0.4}>
<rect x={63} y={33} width={52} height={6} rx={3} />
<rect x={63} y={42} width={52} height={6} rx={3} />
<rect x={63} y={51} width={52} height={6} rx={3} />
</g>
{/* AFTER: crisp thin scanlines + sweep + star; pawn brightens up */}
<Pawn x={186} yb={80} s={8} eyes host gaze={2} />
<MiniTv x={212} y={22} w={76} h={46} />
<g fill={MINT} opacity={0.95}>
<rect x={221} y={30} width={58} height={2.5} rx={1.25} />
<rect x={221} y={35} width={58} height={2.5} rx={1.25} />
<rect x={221} y={40} width={58} height={2.5} rx={1.25} />
<rect x={221} y={45} width={58} height={2.5} rx={1.25} />
<rect x={221} y={50} width={58} height={2.5} rx={1.25} />
<rect x={221} y={55} width={58} height={2.5} rx={1.25} />
</g>
<rect className="vls-vq-sweep" x={218} y={28} width={9} height={30} fill="#ffffff" opacity={0} />
<Star x={294} y={17} r={7} className="vls-vq-star" baseOpacity={0} />
</>
);
<Frame x={4} w={152} theme={theme} /><Frame x={164} w={152} theme={theme} result />
{[0, 1, 2].map(index => <g key={index} transform={`translate(${index * 43} 0)`} stroke="var(--ink)" fill="none" strokeWidth={2}>
<rect x={20} y={25} width={32} height={43} rx={5} />
{[0, 1, ...(index ? [2] : [])].map(line => <path key={line} d={`M26 ${35 + line * 7}h20`} />)}
{index === 2 ? <path d="M25 59h15m-4-3 4 3-4 3" /> : null}
</g>)}
<path className="vls-quality-choice" d="M69 75h19" stroke="var(--ink)" strokeWidth={3} strokeLinecap="round" />
<Pawn x={190} yb={80} s={9} eyes host gaze={2} />
<MiniTv x={220} y={25} w={70} h={43} />
<path className="vls-quality-size" pathLength={1} d="M220 79h70m-70-4v8m70-8v8M300 25v43m-4-43h8m-8 43h8" stroke="var(--ink)" strokeWidth={2} fill="none" />
</>;
/** hint-audio-quality: speaker with one thin faint arc (pawn leans in to
* hear) → three bold rich arcs (happy-eye pawn static, star). Demonstrates once, then holds. */
const hintAudioQuality: HintScene = ({ theme }) => (
<>
<style>{`
.vls-aq-weak{animation:vlsAqWeak var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-aq-listen{transform-box:fill-box;transform-origin:50% 100%;animation:vlsAqListen var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-aq-a1{animation:vlsAqPing var(--comic-duration,3.2s) ease-out var(--comic-repeat,1) both}
.vls-aq-a2{animation:vlsAqPing var(--comic-duration,3.2s) ease-out .08s var(--comic-repeat,1) both}
.vls-aq-a3{animation:vlsAqPing var(--comic-duration,3.2s) ease-out .16s var(--comic-repeat,1) both}
.vls-aq-star{transform-box:fill-box;transform-origin:center;animation:vlsAqStar var(--comic-duration,3.2s) cubic-bezier(.3,1.5,.5,1) var(--comic-repeat,1) both}
@keyframes vlsAqWeak{0%,30%,100%{opacity:.4}12%{opacity:.6}22%{opacity:.32}}
@keyframes vlsAqListen{0%,30%,58%,100%{transform:rotate(0)}40%,50%{transform:rotate(-8deg)}}
@keyframes vlsAqPing{0%,5%{opacity:.25}12%,100%{opacity:1}}
@keyframes vlsAqStar{0%,46%{opacity:0;transform:scale(0)}54%{opacity:1;transform:scale(1.25)}60%,100%{opacity:1;transform:scale(1)}}
${rmBlock(
["vls-aq-weak", "vls-aq-listen", "vls-aq-a1", "vls-aq-a2", "vls-aq-a3", "vls-aq-star"],
[
[".vls-aq-weak", "opacity:.4"],
[".vls-aq-listen,.vls-aq-star", "transform:none"],
[".vls-aq-a1,.vls-aq-a2,.vls-aq-a3,.vls-aq-star", "opacity:1"],
],
)}
const hintAudioQuality: HintScene = ({ theme }) => <>
<style>{`
.vls-audio-quality-wave{stroke-dasharray:1;animation:vlsAudioQualityWave var(--comic-duration,3.2s) ease-out 1 both}
@keyframes vlsAudioQualityWave{0%,8%{stroke-dashoffset:1}38%,100%{stroke-dashoffset:0}}
${rmBlock(["vls-audio-quality-wave"], [[".vls-audio-quality-wave", "stroke-dashoffset:0"]], false)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} result />
{/* BEFORE: thin faint arc, pawn cups its ear and leans in */}
<rect x={40} y={42} width={13} height={18} rx={3} fill={TV_BODY} stroke={TV_EDGE} strokeWidth={2} />
<path d="M53 47 L67 38 V64 L53 55 Z" fill={TV_BODY} stroke={TV_EDGE} strokeWidth={2} strokeLinejoin="round" />
<path className="vls-aq-weak" d="M73 45 a9 9 0 0 1 0 12" stroke={LINE} strokeWidth={2} strokeLinecap="round" fill="none" opacity={0.4} />
<g className="vls-aq-listen">
<Pawn x={122} yb={80} s={9} eyes host gaze={-2} />
<circle cx={115} cy={61} r={2} fill={YOU} />
</g>
{/* AFTER: three bold arcs, happy-eye pawn static, star */}
<rect x={200} y={42} width={13} height={18} rx={3} fill={TV_BODY} stroke={TV_EDGE} strokeWidth={2} />
<path d="M213 47 L227 38 V64 L213 55 Z" fill={TV_BODY} stroke={TV_EDGE} strokeWidth={2} strokeLinejoin="round" />
<g stroke={LIVE} strokeWidth={2.5} strokeLinecap="round" fill="none">
<path className="vls-aq-a1" d="M232 46 a8 8 0 0 1 0 10" />
<path className="vls-aq-a2" d="M237 41 a13 13 0 0 1 0 20" />
<path className="vls-aq-a3" d="M242 36 a18 18 0 0 1 0 30" />
</g>
<Pawn x={282} yb={80} s={9} eyes="closed" host />
<Star x={257} y={21} r={6.5} className="vls-aq-star" baseOpacity={0} />
</>
);
<Frame x={4} w={152} theme={theme} /><Frame x={164} w={152} theme={theme} result />
{[0, 1].map(index => <g key={index} transform={`translate(${index * 160} 0)`}>
<Pawn x={26} yb={81} s={8} eyes host />
<path d="M45 33h8l13-9v34l-13-9h-8Z" fill="none" stroke="var(--ink)" strokeWidth={2} strokeLinejoin="round" />
<path d="M76 36q7 5 0 10" fill="none" stroke="var(--ink)" strokeWidth={2} strokeLinecap="round" />
<path className={index ? "vls-audio-quality-wave" : undefined} pathLength={index ? 1 : undefined}
d="M93 44l8-12 9 24 8-12h15" fill="none" stroke="var(--ink)" strokeWidth={2}
strokeDasharray={index ? undefined : "3 4"} />
</g>)}
<text x={80} y={88} fill="var(--ink)" fontSize={11} textAnchor="middle">64 / 128 / 192</text>
<text x={240} y={88} fill="var(--ink)" fontSize={11} textAnchor="middle">kb/s</text>
</>;
/** hint-degrade-pref: balance scale weighing a crisp frame against a
* motion-blur frame (beam wobbles) → scale settles level, star. Demonstrates once, then holds. */
const hintDegradePref: HintScene = ({ theme }) => (
<>
function PreferenceHint({ theme, preference }: Parameters<HintScene>[0] & {
preference: "resolution" | "balanced" | "framerate";
}) {
const detail = preference === "resolution";
const motion = preference === "framerate";
return <>
<style>{`
.vls-dp-beam{transform-box:fill-box;transform-origin:50% 0%;animation:vlsDpBeam var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-dp-settle{transform-box:fill-box;transform-origin:50% 0%;animation:vlsDpSettle var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-dp-star{transform-box:fill-box;transform-origin:center;animation:vlsDpStar var(--comic-duration,3.2s) cubic-bezier(.3,1.5,.5,1) var(--comic-repeat,1) both}
@keyframes vlsDpBeam{0%,40%,100%{transform:rotate(0)}12%{transform:rotate(5deg)}26%{transform:rotate(-4deg)}}
@keyframes vlsDpSettle{0%,16%,100%{transform:rotate(0)}4%{transform:rotate(3deg)}10%{transform:rotate(-2deg)}}
@keyframes vlsDpStar{0%,30%{opacity:0;transform:scale(0)}40%{opacity:1;transform:scale(1.25)}48%,100%{opacity:1;transform:scale(1)}}
${rmBlock(
["vls-dp-beam", "vls-dp-settle", "vls-dp-star"],
[
[".vls-dp-beam,.vls-dp-settle,.vls-dp-star", "transform:none"],
[".vls-dp-star", "opacity:1"],
],
)}
.vls-pref-${preference}{transform-box:view-box;transform-origin:237px 42px;animation:vlsPref${preference} var(--comic-duration,3.2s) ease-in-out 1 both}
@keyframes vlsPref${preference}{0%,8%{transform:rotate(${detail ? 12 : -12}deg)}24%{transform:rotate(${motion ? 3 : -3}deg)}40%,100%{transform:none}}
${rmBlock([`vls-pref-${preference}`], [[`.vls-pref-${preference}`, "transform:none"]])}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} result />
{/* BEFORE: pawn watches the scale wobble — crisp frame vs blur frame */}
<Pawn x={26} yb={80} s={7.5} eyes host gaze={2} />
<path d="M84 78 V34 M70 78 H98" stroke="var(--ink)" strokeWidth={2.5} strokeLinecap="round" fill="none" />
<g className="vls-dp-beam" stroke="var(--ink)" strokeWidth={2.5} strokeLinecap="round" fill="none">
<path d="M52 34 H116" />
<path d="M52 34 V44 M116 34 V44" strokeWidth={2} />
<path d="M44 44 Q52 51 60 44 M44 44 H60" strokeWidth={2} />
<path d="M108 44 Q116 51 124 44 M108 44 H124" strokeWidth={2} />
<rect x={46.5} y={35} width={11} height={9} rx={1.5} fill="var(--paper)" strokeWidth={2} />
<rect x={50.2} y={38} width={3.6} height={3.6} rx={0.8} fill="var(--ink)" stroke="none" />
<rect x={104.5} y={35} width={11} height={9} rx={1.5} stroke={FAINT} strokeWidth={2} opacity={0.25} />
<rect x={107} y={35} width={11} height={9} rx={1.5} stroke={FAINT} strokeWidth={2} opacity={0.5} />
<rect x={109.5} y={35} width={11} height={9} rx={1.5} fill="var(--paper)" strokeWidth={2} />
</g>
<circle cx={84} cy={34} r={2.5} fill="var(--ink)" />
{/* AFTER: settle once, level, star; pawn pleased */}
<Pawn x={298} yb={80} s={7.5} eyes="closed" host />
<path d="M244 78 V34 M230 78 H258" stroke="var(--ink)" strokeWidth={2.5} strokeLinecap="round" fill="none" />
<g className="vls-dp-settle" stroke="var(--ink)" strokeWidth={2.5} strokeLinecap="round" fill="none">
<path d="M212 34 H276" />
<path d="M212 34 V44 M276 34 V44" strokeWidth={2} />
<path d="M204 44 Q212 51 220 44 M204 44 H220" strokeWidth={2} />
<path d="M268 44 Q276 51 284 44 M268 44 H284" strokeWidth={2} />
<rect x={206.5} y={35} width={11} height={9} rx={1.5} fill="var(--paper)" strokeWidth={2} />
<rect x={210.2} y={38} width={3.6} height={3.6} rx={0.8} fill="var(--ink)" stroke="none" />
<rect x={264.5} y={35} width={11} height={9} rx={1.5} stroke={FAINT} strokeWidth={2} opacity={0.25} />
<rect x={267} y={35} width={11} height={9} rx={1.5} stroke={FAINT} strokeWidth={2} opacity={0.5} />
<rect x={269.5} y={35} width={11} height={9} rx={1.5} fill="var(--paper)" strokeWidth={2} />
</g>
<circle cx={244} cy={34} r={2.5} fill="var(--ink)" />
<Star x={244} y={16} r={7} className="vls-dp-star" baseOpacity={0} />
</>
);
<Frame x={4} w={152} theme={theme} /><Frame x={164} w={152} theme={theme} result />
<Pawn x={25} yb={81} s={8} eyes host />
<path d="M83 79V40m-14 39h28M49 40h68m-68 0v12m68-12v12M39 52q10 12 20 0m48 0q10 12 20 0" stroke="var(--ink)" strokeWidth={2} fill="none" />
<rect x={41} y={26} width={17} height={14} rx={2} stroke="var(--ink)" strokeWidth={2} fill="none" />
<path d="m44 37 5-7 6 7M105 27h15v13m-20-9h15v13m-20-9h15v13" stroke="var(--ink)" strokeWidth={1.5} fill="none" />
<Pawn x={294} yb={81} s={8} eyes host />
<path d="M237 79V42m-14 37h28" stroke="var(--ink)" strokeWidth={2} fill="none" />
<g className={`vls-pref-${preference}`}><g transform={`rotate(${detail ? -12 : motion ? 12 : 0} 237 42)`} stroke="var(--ink)" strokeWidth={2} fill="none">
<path d="M199 42h76m-76 0v14m76-14v14M189 56q10 12 20 0m56 0q10 12 20 0" />
<rect x={190} y={27} width={18} height={15} rx={2} />
<path d="m193 39 5-8 7 8M265 27h15v13m-20-9h15v13m-20-9h15v13" />
</g></g>
<path d={detail ? "M188 19h22" : motion ? "M260 19h22" : "M229 22h16m-16 6h16"}
stroke="var(--ink)" strokeWidth={3} strokeLinecap="round" />
</>;
}
const hintDegradePref: HintScene = props => <PreferenceHint {...props} preference="balanced" />;
/** hint-codec: puzzle piece with eyes floats beside a TV with a dashed
* socket → piece slots in, LED goes live, star. Demonstrates once, then holds. */
const hintCodec: HintScene = ({ theme }) => (
<>
<style>{`
.vls-cd-bob{animation:vlsCdBob var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-cd-dock{animation:vlsCdDock var(--comic-duration,3.2s) cubic-bezier(.3,1.5,.5,1) var(--comic-repeat,1) both}
.vls-cd-star{transform-box:fill-box;transform-origin:center;animation:vlsCdStar var(--comic-duration,3.2s) cubic-bezier(.3,1.5,.5,1) var(--comic-repeat,1) both}
@keyframes vlsCdBob{0%,40%,100%{transform:translateY(0)}20%{transform:translateY(-2.5px)}}
@keyframes vlsCdDock{0%,6%{transform:translateX(-8px)}20%{transform:translateX(1px)}26%,100%{transform:translateX(0)}}
@keyframes vlsCdStar{0%,32%{opacity:0;transform:scale(0)}42%{opacity:1;transform:scale(1.25)}50%,100%{opacity:1;transform:scale(1)}}
${rmBlock(
["vls-cd-bob", "vls-cd-dock", "vls-cd-star"],
[
[".vls-cd-bob,.vls-cd-dock,.vls-cd-star", "transform:none"],
[".vls-cd-star", "opacity:1"],
],
)}
const hintCodec: HintScene = ({ theme }) => <>
<style>{`
.vls-codec-piece{animation:vlsCodecPiece var(--comic-duration,3.2s) ease-in-out 1 both}
.vls-codec-packets{transform-box:fill-box;transform-origin:left;animation:vlsCodecPackets var(--comic-duration,3.2s) ease-out 1 both}
@keyframes vlsCodecPiece{0%,8%{transform:translateX(-10px)}28%,100%{transform:none}}
@keyframes vlsCodecPackets{0%,18%{transform:scaleX(0)}42%,100%{transform:none}}
${rmBlock(["vls-codec-piece", "vls-codec-packets"], [[".vls-codec-piece,.vls-codec-packets", "transform:none"]])}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} result />
{/* BEFORE: piece bobs beside the TV, dashed socket waits on the edge */}
<g className="vls-cd-bob">
<path
d="M40 34 a4 4 0 0 1 4-4 h16 a4 4 0 0 1 4 4 v5 a3.5 3.5 0 0 1 0 7 v4 a4 4 0 0 1-4 4 h-16 a4 4 0 0 1-4-4 Z"
fill={STAR_GOLD}
stroke="var(--ink)"
strokeWidth={2}
strokeLinejoin="round"
/>
<circle cx={48.5} cy={39} r={1.5} fill="#101a2c" />
<circle cx={55.5} cy={39} r={1.5} fill="#101a2c" />
</g>
<MiniTv x={92} y={22} w={52} h={40} />
<path d="M92 37 a5 5 0 0 1 0 10" stroke={FAINT} strokeWidth={2} strokeLinecap="round" strokeDasharray="3 3" fill="none" />
<circle cx={118} cy={68} r={2.5} fill={TV_EDGE} />
{/* AFTER: piece docked flush, happy eyes, LED live, star */}
<MiniTv x={252} y={22} w={52} h={40} />
<circle cx={278} cy={68} r={2.5} fill={LIVE} />
<g className="vls-cd-dock">
<path
d="M224 34 a4 4 0 0 1 4-4 h16 a4 4 0 0 1 4 4 v5 a3.5 3.5 0 0 1 0 7 v4 a4 4 0 0 1-4 4 h-16 a4 4 0 0 1-4-4 Z"
fill={STAR_GOLD}
stroke="var(--ink)"
strokeWidth={2}
strokeLinejoin="round"
/>
<path d="M234 40 q1.8 -2.4 3.6 0 M241 40 q1.8 -2.4 3.6 0" stroke="#101a2c" strokeWidth={1.8} strokeLinecap="round" fill="none" />
</g>
<Star x={284} y={16} r={7} className="vls-cd-star" baseOpacity={0} />
</>
);
<Frame x={4} w={152} theme={theme} /><Frame x={164} w={152} theme={theme} result />
<MiniTv x={28} y={24} w={60} h={39} />
<path d="M97 32h10a4 4 0 1 0 8 0h10v10a4 4 0 1 0 0 8v10h-28Z" fill="var(--paper)" stroke="var(--ink)" strokeWidth={2} />
<path className="vls-codec-piece" d="M183 33h10a4 4 0 1 0 8 0h10v10a4 4 0 1 0 0 8v10h-28Z" fill="var(--paper)" stroke="var(--ink)" strokeWidth={2} />
<path className="vls-codec-packets" d="M227 36h60m-60 12h60m-60 12h60" stroke="var(--ink)" strokeWidth={2} strokeDasharray="9 6" />
</>;
/** hint-advanced: closed cabinet door with a sliders glyph → door swings
* open revealing 3 slider tracks with set knobs; pawn hops. Demonstrates once, then holds. */
@@ -257,7 +134,7 @@ ${rmBlock(
<path d="M215 48 H251" />
<path d="M215 62 H251" />
</g>
<circle cx={222} cy={34} r={4} fill={LIVE} stroke="var(--ink)" strokeWidth={1.5} />
<circle cx={222} cy={34} r={4} fill="var(--ink)" stroke="var(--ink)" strokeWidth={1.5} />
<circle cx={242} cy={48} r={4} fill={STAR_GOLD} stroke="var(--ink)" strokeWidth={1.5} />
<circle cx={230} cy={62} r={4} fill={SKY} stroke="var(--ink)" strokeWidth={1.5} />
<g className="vls-adv-door">
@@ -361,10 +238,10 @@ ${rmBlock(
</g>
<g className="vls-mm-r2">
<rect x={194} y={46} width={92} height={15} rx={7.5} fill="var(--wall-2)" stroke="var(--ink)" strokeWidth={2} />
<rect x={206} y={52} width={5} height={5} rx={1.5} fill={LIVE} />
<rect x={216} y={49} width={5} height={8} rx={1.5} fill={LIVE} />
<rect x={226} y={51} width={5} height={6} rx={1.5} fill={LIVE} />
<circle cx={240} cy={53.5} r={2} fill={LIVE} />
<rect x={206} y={52} width={5} height={5} rx={1.5} fill="var(--ink)" />
<rect x={216} y={49} width={5} height={8} rx={1.5} fill="var(--ink)" />
<rect x={226} y={51} width={5} height={6} rx={1.5} fill="var(--ink)" />
<circle cx={240} cy={53.5} r={2} fill="var(--ink)" />
</g>
<g className="vls-mm-r3">
<rect x={194} y={64} width={92} height={15} rx={7.5} fill="var(--wall-2)" stroke="var(--ink)" strokeWidth={2} />
@@ -379,6 +256,13 @@ ${rmBlock(
const hintDebugExport: HintScene = ({ theme }) => (
<>
<style>{`
.vls-export-report{animation:vlsExportReport var(--comic-duration,3.2s) ease-out var(--comic-repeat,1) both}
.vls-export-arrow{animation:vlsExportArrow var(--comic-duration,3.2s) ease-out var(--comic-repeat,1) both}
@keyframes vlsExportReport{0%,8%{opacity:0;transform:translateY(-8px)}30%,100%{opacity:1;transform:none}}
@keyframes vlsExportArrow{0%,26%{opacity:0}42%,100%{opacity:1}}
${rmBlock(["vls-export-report", "vls-export-arrow"], [[".vls-export-report,.vls-export-arrow", "opacity:1;transform:none"]])}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} result />
<path d="M32 72H128" stroke="var(--ink)" strokeWidth={2.5} strokeLinecap="round" />
@@ -388,9 +272,13 @@ const hintDebugExport: HintScene = ({ theme }) => (
<rect x={84} y={46} width={12} height={26} rx={2.5} />
<rect x={106} y={26} width={12} height={46} rx={2.5} />
</g>
<rect x={218} y={14} width={44} height={42} rx={5} fill="var(--paper)" stroke="var(--ink)" strokeWidth={2.5} />
<path d="M228 45V35M240 45V25M252 45V31" stroke={LIVE} strokeWidth={5} strokeLinecap="round" />
<path d="M240 61V73M234 68L240 74L246 68M215 74V84H265V74" fill="none"
<g className="vls-export-report">
<rect x={218} y={14} width={44} height={42} rx={5} fill="var(--paper)" stroke="var(--ink)" strokeWidth={2.5} />
<path d="M228 45V35M240 45V25M252 45V31" stroke="var(--ink)" strokeWidth={5} strokeLinecap="round" />
</g>
<path className="vls-export-arrow" d="M240 61V73M234 68L240 74L246 68" fill="none"
stroke="var(--ink)" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" />
<path d="M215 74V84H265V74" fill="none"
stroke="var(--ink)" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" />
</>
);
@@ -399,6 +287,8 @@ export const SET3_SCENES: Record<Set3Kind, HintScene> = {
"hint-quality": hintQuality,
"hint-audio-quality": hintAudioQuality,
"hint-degrade-pref": hintDegradePref,
"hint-prefer-resolution": props => <PreferenceHint {...props} preference="resolution" />,
"hint-prefer-framerate": props => <PreferenceHint {...props} preference="framerate" />,
"hint-codec": hintCodec,
"hint-advanced": hintAdvanced,
"hint-details": hintDetails,
+29 -20
View File
@@ -8,7 +8,6 @@ import {
BrowserWindow,
FAINT,
Frame,
LIVE,
MiniTv,
Pawn,
RedX,
@@ -27,7 +26,7 @@ const INK = "var(--ink)";
const WALL2 = "var(--wall-2)";
/* hint-topology: [one pawn with a Host badge] → [host with two leaf
pawns on live-green edges]. Demo: leaves hop in turn (8-20%, 26-38%),
pawns on neutral edges]. Demo: leaves hop in turn (8-20%, 26-38%),
everyone blinks at 62%; rest >=55%. */
const HintTopology: HintScene = ({ theme }) => (
<>
@@ -46,13 +45,13 @@ ${rmBlock(
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} result />
<Pawn x={80} yb={78} s={12} eyes host eyeClassName="vls-top-eyes" />
<g stroke={LIVE} strokeWidth={3} strokeLinecap="round" fill="none">
<g stroke={INK} strokeWidth={3} strokeLinecap="round" fill="none">
<path d="M240 47 C231 50 218 52 210 56" />
<path d="M240 47 C249 50 262 52 270 56" />
</g>
<Pawn x={240} yb={46} s={7} eyes host eyeClassName="vls-top-eyes" />
<Pawn x={210} yb={80} s={7} color={SKY} eyes className="vls-top-leaf1" />
<Pawn x={270} yb={80} s={7} color={STAR_GOLD} eyes className="vls-top-leaf2" />
<Pawn x={270} yb={80} s={7} color="var(--pawn-3)" eyes className="vls-top-leaf2" />
</>
);
@@ -249,7 +248,7 @@ ${rmBlock(
</g>
<path d="M198 76H282" stroke={INK} strokeWidth={2} strokeLinecap="round" />
<rect x={222} y={78} width={36} height={3} rx={1.5} fill="var(--couch)" />
<g className="vls-th-arrows" stroke={LIVE} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none">
<g className="vls-th-arrows" stroke={INK} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none">
<path d="M196 32l13 11m0-9v9h-9M284 32l-13 11m0-9v9h9M196 72l13-11m0 9v-9h-9M284 72l-13-11m0 9v-9h9" />
</g>
</>
@@ -261,7 +260,7 @@ ${rmBlock(
<g className={stageClass}>
<MiniTv x={188} y={32} w={104} h={44} />
</g>
<g className="vls-th-arrows" stroke={LIVE} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none">
<g className="vls-th-arrows" stroke={INK} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" fill="none">
<path d="M211 46l-13-12m0 9v-9h9M269 46l13-12m0 9v-9h-9M211 58l-13 12m0-9v9h9M269 58l13 12m0-9v9h-9" />
</g>
</>
@@ -278,8 +277,8 @@ const HintTheaterExit: HintScene = ({ theme }) => (
<TheaterHint theme={theme} exit />
);
/* hint-route-p2p: [two pawns apart] → [direct live-green arc pawn→pawn,
live dot on the apex]. Demo: arc draws 6-28%, both pawns hop 30-42%,
/* hint-route-p2p: [two pawns apart] → [direct neutral arc pawn→pawn,
route dot on the apex]. Demo: arc draws 6-28%, both pawns hop 30-42%,
panel-1 pair blinks at 64%; rest ~58%. */
const HintRouteP2p: HintScene = ({ theme }) => (
<>
@@ -314,12 +313,12 @@ ${rmBlock(
className="vls-p2p-arc"
d="M213 58 Q240 30 267 58"
pathLength={1}
stroke={LIVE}
stroke={INK}
strokeWidth={3}
strokeLinecap="round"
fill="none"
/>
<circle cx={240} cy={44} r={3.5} fill={LIVE} />
<circle cx={240} cy={44} r={3.5} fill={INK} />
</>
);
@@ -327,13 +326,20 @@ ${rmBlock(
const HintRouteP2pRequired: HintScene = ({ theme }) => (
<>
<HintRouteP2p theme={theme} />
<ServerBox x={64} y={16} w={32} h={24} />
<RedX cx={80} cy={28} arm={7} />
<style>{`
.vls-p2p-unavailable{animation:vlsP2pUnavailable var(--comic-duration,3.2s) ease-in-out 1 both}
@keyframes vlsP2pUnavailable{0%,8%,36%,100%{transform:none}16%{transform:translateX(-4px)}26%{transform:translateX(3px)}}
${rmBlock(["vls-p2p-unavailable"], [[".vls-p2p-unavailable", "transform:none"]], false)}
`}</style>
<g className="vls-p2p-unavailable">
<ServerBox x={64} y={16} w={32} h={24} />
<RedX cx={80} cy={28} arm={7} />
</g>
</>
);
/* hint-route-sfu: [two pawns apart] → [two live-green arcs detour up
through a lit server box]. Demo: left arc 6-24%, right arc 14-32%,
/* hint-route-sfu: [two pawns apart] → [two neutral arcs detour up
through a server box]. Demo: left arc 6-24%, right arc 14-32%,
both pawns hop 34-46%, panel-1 pair blinks at 64%; rest ~54%. */
const HintRouteSfu: HintScene = ({ theme }) => (
<>
@@ -362,7 +368,7 @@ ${rmBlock(
<circle cx={74} cy={60} r={1.5} />
<circle cx={88} cy={60} r={1.5} />
</g>
<ServerBox x={228} y={12} lit />
<ServerBox x={228} y={12} />
<g className="vls-sfu-hop">
<Pawn x={200} yb={76} s={9} eyes />
<Pawn x={280} yb={76} s={9} color={SKY} eyes />
@@ -371,7 +377,7 @@ ${rmBlock(
className="vls-sfu-a1"
d="M209 60 Q219 34 232 29"
pathLength={1}
stroke={LIVE}
stroke={INK}
strokeWidth={3}
strokeLinecap="round"
fill="none"
@@ -380,7 +386,7 @@ ${rmBlock(
className="vls-sfu-a2"
d="M271 60 Q261 34 248 29"
pathLength={1}
stroke={LIVE}
stroke={INK}
strokeWidth={3}
strokeLinecap="round"
fill="none"
@@ -392,7 +398,7 @@ ${rmBlock(
const HintClientLocal: HintScene = ({ theme }) => (
<>
<HintRouteP2p theme={theme} />
<g fill="none" stroke={LIVE} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round">
<g fill="none" stroke={INK} strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round">
<path d="M173 20 180 14 187 20v7h-14Z" />
<path d="M178 27v-5h4v5" />
</g>
@@ -434,12 +440,15 @@ const HintNatPrediction = ({ theme, available = true }: { theme: ComicTheme; ava
<style>{`
.vls-nat-path{stroke-dasharray:1;animation:vlsNatPath var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-nat-dots{animation:vlsNatDots var(--comic-duration,3.2s) ease-in-out var(--comic-repeat,1) both}
.vls-nat-unavailable{transform-box:fill-box;transform-origin:center;animation:vlsNatUnavailable var(--comic-duration,3.2s) ease-in-out 1 both}
@keyframes vlsNatPath{0%,8%{stroke-dashoffset:1}30%,100%{stroke-dashoffset:0}}
@keyframes vlsNatDots{0%,32%,100%{opacity:.35}44%{opacity:1}}
@keyframes vlsNatUnavailable{0%,8%{transform:scale(1.3) rotate(-8deg)}28%,100%{transform:none}}
${rmBlock(
["vls-nat-path", "vls-nat-dots"],
[[".vls-nat-path", "stroke-dashoffset:0"], [".vls-nat-dots", "opacity:1"]],
)}
${rmBlock(["vls-nat-unavailable"], [[".vls-nat-unavailable", "transform:none"]], false)}
`}</style>
<Frame x={4} w={152} theme={theme} />
<Frame x={164} w={152} theme={theme} result />
@@ -461,14 +470,14 @@ ${rmBlock(
<Pawn x={278} yb={76} s={9} color={SKY} eyes />
<path d="M211 68 Q240 56 269 68" fill="none" stroke={FAINT} strokeWidth={2.5} strokeLinecap="round" />
<g className={available ? "vls-nat-path" : undefined} fill="none"
stroke={available ? LIVE : FAINT} strokeWidth={2.5} strokeLinecap="round"
stroke={available ? INK : FAINT} strokeWidth={2.5} strokeLinecap="round"
strokeDasharray={available ? undefined : "4 4"}>
<path d="M211 58 Q240 10 269 58" pathLength={available ? 1 : undefined} />
<path d="M211 58 Q240 30 269 58" pathLength={available ? 1 : undefined} />
</g>
{available ? <Spark x={240} y={24} /> : <>
<circle cx={240} cy={39} r={11} fill={theme === "paper" ? "var(--paper)" : "#0d1526"} />
<RedX cx={240} cy={39} arm={7} />
<RedX cx={240} cy={39} arm={7} className="vls-nat-unavailable" />
</>}
</>
);
@@ -0,0 +1,34 @@
import type { CopyKey } from "../../ui/copy";
import type { GlyphName } from "../../ui/icons";
import type { HintKind } from "../../ui/visual-kinds";
// The same metric keeps its icon and explanation in detail cells and headings.
export const METRIC_PRESENTATION = {
"stats.resolution": { icon: "expand", hint: "hint-metric-resolution" },
"stats.fps": { icon: "frames", hint: "hint-metric-fps" },
"stats.bitrate": { icon: "gauge", hint: "hint-metric-bitrate" },
"stats.loss": { icon: "packetLoss", hint: "hint-metric-loss" },
"stats.rtt": { icon: "clock", hint: "hint-metric-rtt" },
"stats.codec": { icon: "puzzle", hint: "hint-codec" },
"stats.outgoing": { icon: "gauge", hint: "hint-metric-outgoing" },
"stats.qualityState": { icon: "sliders", hint: "hint-metric-quality" },
"stats.capture": { icon: "share", hint: "hint-metric-capture" },
"stats.inputFps": { icon: "frames", hint: "hint-metric-input-fps" },
"stats.encoder": { icon: "cpu", hint: "hint-metric-encoder" },
"stats.encodeMs": { icon: "clock", hint: "hint-metric-encode-time" },
"stats.jitter": { icon: "jitter", hint: "hint-metric-jitter" },
"stats.dropped": { icon: "frameDrop", hint: "hint-metric-dropped" },
"stats.decodeMs": { icon: "clock", hint: "hint-metric-decode-time" },
"stats.freezeCount": { icon: "pause", hint: "hint-metric-freezes" },
"stats.freezeDuration": { icon: "clock", hint: "hint-metric-freeze-time" },
"stats.audio": { icon: "speaker", hint: "hint-metric-audio-bitrate" },
"stats.audioLoss": { icon: "packetLoss", hint: "hint-metric-audio-loss" },
"stats.audioJitter": { icon: "jitter", hint: "hint-metric-audio-jitter" },
"stats.playoutDelta": { icon: "clock", hint: "hint-metric-playout" },
"stats.videoBuffer": { icon: "clock", hint: "hint-metric-video-buffer" },
"stats.audioBuffer": { icon: "clock", hint: "hint-metric-audio-buffer" },
"stats.audioConcealedRate": { icon: "audioRepair", hint: "hint-metric-concealment-rate" },
"stats.audioConcealed": { icon: "audioRepair", hint: "hint-metric-concealments" },
} satisfies Partial<Record<CopyKey, { icon: GlyphName; hint: HintKind }>>;
export type MetricLabel = keyof typeof METRIC_PRESENTATION;
@@ -15,6 +15,8 @@
.lr-playback.is-hidden { opacity: 0; transform: translateY(6px); pointer-events: none; }
.lr-tv-screen:has(> .lr-playback[data-can-play="true"]) > video { cursor: pointer; }
.lr-tv-screen:has(> .lr-playback.is-hidden) > video { cursor: none; }
/* The video's own focus outline is clipped by the screen; mark its container. */
.lr-tv-screen:has(> .lr-playback):has(:focus-visible) { outline: 3px solid var(--action); outline-offset: 2px; }
.lr-playback .lr-btn {
display: grid; place-items: center; flex: none;
min-width: 0; width: 44px; height: 44px; padding: 0;
@@ -28,7 +30,7 @@
.lr-playback-audio { display: flex; align-items: center; gap: 8px; min-width: 0; }
.lr-playback-separator { width: 1px; height: 20px; background: currentColor; opacity: .22; }
.lr-playback-volume { min-width: 0; }
.lr-playback-volume input { width: clamp(64px, 14vw, 144px); min-width: 0; height: 36px; padding: 0; margin: 0; accent-color: var(--action); cursor: pointer; }
.lr-playback-volume input { width: clamp(64px, 14vw, 144px); min-width: 0; height: 44px; padding: 0; margin: 0; accent-color: var(--action); cursor: pointer; }
.lr-playback-level { width: 4ch; text-align: right; font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums; }
.lr-playback-level.is-boosted { color: var(--action); }
.lr-playback-view { display: inline-flex; align-items: center; gap: 4px; margin-left: auto; border-left: 1px solid var(--wall-2); padding-left: 8px; }
+23 -23
View File
@@ -51,6 +51,7 @@ export function Cap({ k }: { k: CopyKey }) {
}
export function Btn({
id,
icon,
cap,
tone,
@@ -67,6 +68,7 @@ export function Btn({
hintMotion,
draw,
}: {
id?: string;
icon: GlyphName;
cap?: CopyKey;
tone?: "primary" | "danger" | "on";
@@ -79,7 +81,7 @@ export function Btn({
onClick?: () => void;
type?: "button" | "submit";
/** Shared hint comic; text modes add the localized caption. */
hint?: HintKind;
hint?: ComicKind | HintKind;
hintTone?: ComicTone;
hintMotion?: ComicMotion;
/** Draw-in spot id for state-beat toggle icons (see Glyph). */
@@ -87,10 +89,11 @@ export function Btn({
}) {
const { t, vis } = useCopy();
const label = t(title);
const wrapped = !vis || Boolean(hint);
const wrapped = Boolean(hint);
const softDisabled = Boolean(wrapped && disabled);
const button = (
<button
id={id}
type={type}
className={`lr-btn${tone ? ` is-${tone}` : ""}`}
aria-label={label}
@@ -122,7 +125,7 @@ export function Btn({
{cap ? <Cap k={cap} /> : null}
</button>
);
return wrapped ? (
return hint ? (
<Tooltip kind={hint} tone={hintTone} motion={hintMotion} text={vis ? undefined : label}>{button}</Tooltip>
) : (
button
@@ -150,7 +153,7 @@ export function Chip({
children: ReactNode;
}) {
const { vis } = useCopy();
const wrapped = !vis || Boolean(hint);
const wrapped = Boolean(hint);
const softDisabled = Boolean(wrapped && disabled);
const chip = (
<button
@@ -177,7 +180,7 @@ export function Chip({
{children}
</button>
);
return wrapped ? (
return hint ? (
<Tooltip kind={hint} text={vis ? undefined : title}>{chip}</Tooltip>
) : (
chip
@@ -220,33 +223,30 @@ export function Pill({
label,
alert,
comic,
tooltipTone,
motion = "still",
}: {
icon: GlyphName;
tone?: "bad" | "good";
tone?: ComicTone;
label: string;
alert?: boolean;
comic?: ComicKind | HintKind;
tooltipTone?: ComicTone;
comic: ComicKind | HintKind;
motion?: ComicMotion;
}) {
const { vis } = useCopy();
const wrapped = !vis || Boolean(comic);
const Trigger = wrapped ? "button" : "span";
const resolvedTone = tone ?? "warn";
const body = (
<Trigger
type={wrapped ? "button" : undefined}
className={`lr-pill${tone ? ` is-${tone}` : ""}`}
<button
type="button"
className={`lr-pill is-${resolvedTone === "live" ? "good" : resolvedTone}`}
>
<Glyph name={icon} size={16} />
{vis ? <span className="visually-hidden">{label}</span> : <span>{label}</span>}
</Trigger>
</button>
);
return (
<span role={alert ? "alert" : "status"}>
{wrapped ? (
<Tooltip toggleOnClick kind={comic} tone={tooltipTone ?? (tone === "good" ? "live" : tone === "bad" ? "bad" : "warn")}
motion={tone === "good" ? "still" : undefined} text={vis ? undefined : label}>{body}</Tooltip>
) : body}
<Tooltip toggleOnClick kind={comic} tone={resolvedTone} motion={motion}
text={vis ? undefined : label}>{body}</Tooltip>
</span>
);
}
@@ -272,7 +272,7 @@ export function SwitchItem({
hint?: HintKind;
}) {
const { vis, t } = useCopy();
const wrapped = !vis || Boolean(hint);
const wrapped = Boolean(hint);
const softDisabled = Boolean(wrapped && disabled);
const description = note ? `${label} · ${note}` : label;
const tooltipText = disabled || locked ? description
@@ -301,7 +301,7 @@ export function SwitchItem({
{vis ? null : <span className="lr-cap">{label}</span>}
</span>
);
return wrapped ? (
return hint ? (
<Tooltip kind={hint} text={vis ? undefined : tooltipText}>{item}</Tooltip>
) : (
item
@@ -310,8 +310,8 @@ export function SwitchItem({
export function NameTag({ name, identity }: { name: string; identity: string }) {
return (
<Tooltip text={name} className="lr-name-hint">
<span className="lr-name-tag" tabIndex={0}>
<Tooltip overflow={{ text: name, selector: ".lr-name-tag > span" }} className="lr-name-hint">
<span className="lr-name-tag">
<i
aria-hidden="true"
style={{ backgroundColor: participantColor(identity) }}
@@ -1,5 +1,5 @@
.lr-status-indicator {
--status-colour: var(--ink-soft, #8ea3b8);
--status-colour: #8ea3b8;
display: inline-flex;
align-items: center;
justify-content: center;
+26 -7
View File
@@ -99,13 +99,15 @@ export function parseAppRoute(pathname: string): AppRoute {
export interface ClientLaunchBootstrap {
accessToken: string | null;
launchedByClient: boolean;
presentation: ClientLaunchPresentation | null;
presentation: Partial<ClientLaunchPresentation> | null;
}
export interface ClientLaunchPresentation {
lang: Lang;
vis: boolean;
theme: "light" | "dark" | null;
/** Domains explicitly chosen in the launcher, rather than system defaults. */
explicit?: readonly ("copy" | "theme")[];
}
export function clientLaunchURL(target: string, presentation: ClientLaunchPresentation, debug?: boolean): string {
@@ -114,6 +116,12 @@ export function clientLaunchURL(target: string, presentation: ClientLaunchPresen
params.set("piik-lang", presentation.lang);
params.set("piik-mode", presentation.vis ? "vis" : "text");
params.set("piik-theme", presentation.theme ?? "system");
// Keep the public tuple for older Sites. The non-secret provenance goes in
// the query: an unknown fragment key would corrupt their exact #v= parser.
url.searchParams.delete("piik-preferences");
if (presentation.explicit?.length) {
url.searchParams.set("piik-preferences", presentation.explicit.join(","));
}
url.hash = params.toString();
return withBrowserDebug(url.toString(), debug);
}
@@ -127,12 +135,20 @@ export function takeClientLaunchBootstrap(): ClientLaunchBootstrap {
"piik-mode",
"piik-theme",
] as const;
const present = keys.some((key) => params.has(key));
const query = new URLSearchParams(window.location.search);
const present = keys.some((key) => params.has(key)) || query.has("piik-preferences");
const accessValue = params.get("client-access");
const launchedFromFragment = params.get("piik-client") === "1";
const lang = params.get("piik-lang");
const mode = params.get("piik-mode");
const theme = params.get("piik-theme");
const preferenceFields = query.get("piik-preferences")?.split(",") ?? [];
const presentation: Partial<ClientLaunchPresentation> = {
...(isLang(lang) ? { lang } : {}),
...(mode === "vis" || mode === "text" ? { vis: mode === "vis" } : {}),
...(theme === "light" || theme === "dark" || theme === "system"
? { theme: theme === "system" ? null : theme } : {}),
};
let launchedByClient = launchedFromFragment;
try {
if (launchedFromFragment) {
@@ -147,19 +163,22 @@ export function takeClientLaunchBootstrap(): ClientLaunchBootstrap {
const result: ClientLaunchBootstrap = {
accessToken: accessValue || null,
launchedByClient,
presentation: launchedFromFragment && isLang(lang) &&
(mode === "vis" || mode === "text") &&
(theme === "light" || theme === "dark" || theme === "system")
? { lang, vis: mode === "vis", theme: theme === "system" ? null : theme }
presentation: launchedFromFragment && Object.keys(presentation).length > 0
? {
...presentation,
explicit: (["copy", "theme"] as const).filter((key) => preferenceFields.includes(key)),
}
: null,
};
if (present) {
for (const key of keys) params.delete(key);
query.delete("piik-preferences");
const search = query.toString();
const remaining = params.toString();
window.history.replaceState(
window.history.state,
"",
`${window.location.pathname}${window.location.search}${remaining ? `#${remaining}` : ""}`,
`${window.location.pathname}${search ? `?${search}` : ""}${remaining ? `#${remaining}` : ""}`,
);
}
return result;
-12
View File
@@ -93,12 +93,6 @@ function labelPresence<T extends PresenceIdentity>(
});
}
export function labelViewerPresence(
viewers: readonly ViewerPresenceEntry[],
): LabeledViewerPresence[] {
return labelPresence(viewers).sort(comparePeerIdentity);
}
export function labelParticipantSnapshot(
participants: readonly ParticipantPresenceEntry[],
): LabeledParticipantSnapshot {
@@ -117,9 +111,3 @@ export function labelParticipantSnapshot(
.sort(comparePeerIdentity),
};
}
export function labelViewerParticipants(
participants: readonly ParticipantPresenceEntry[],
): LabeledViewerPresence[] {
return labelParticipantSnapshot(participants).viewers;
}
+24 -36
View File
@@ -2,6 +2,7 @@ import type { CopyKey, TitleFrameCatalog } from "./zh";
// Community translations are data-only and type-checked against zh.
export const en: Record<CopyKey, string> = {
"common.loading": "Loading",
"playback.controls": "Local playback controls",
"playback.play": "Play",
"playback.pause": "Pause on this device",
@@ -158,7 +159,7 @@ export const en: Record<CopyKey, string> = {
"host.roomReplaced": "Room code replaced",
"host.roomInvalid": "Room no longer available; press again to create a new one",
"host.roomReplace": "Replace room code",
"host.roomReplaceConfirm": "Confirm replacing the room code",
"host.roomReplaceConfirm": "Replace the room code: this ends the current share and disconnects viewers",
"host.terminated.stale": "Page updated; refresh to continue",
"host.terminated.session": "This page's session was taken over by another tab",
"host.terminated.signal": "Server connection terminated; refresh to continue",
@@ -174,13 +175,13 @@ export const en: Record<CopyKey, string> = {
"host.nameCancel": "Cancel editing",
"host.nameEdit": "Edit display name",
"host.nameError": "Invalid name, or longer than 24 characters",
"host.nameOffline": "Display name can only change while sharing and connected",
"host.nameOffline": "Name saved; others will see it when the connection recovers",
"host.invite": "Invite link",
"host.invite.copy": "Copy invite link",
"host.invite.copyFailed": "Could not copy the invite link; try again",
"host.invite.rotate": "Rotate invite link",
"host.invite.revoke": "Revoke invite link",
"host.invite.rotate": "Rotate invite link; viewers using the old link will need to rejoin",
"host.invite.revoke": "Revoke invite link and disconnect viewers who joined with it",
"host.invite.rotateShort": "Rotate link",
"host.invite.revokeShort": "Revoke link",
"host.invite.updated": "Invite link rotated",
@@ -193,10 +194,11 @@ export const en: Record<CopyKey, string> = {
"host.policy.open": "Open",
"host.policy.private": "Private",
"host.policy.currentOpen": "Room code entry",
"host.policy.currentPassword": "Password entry",
"host.policy.currentPassword": "Room code + password",
"host.policy.currentInvite": "Invite only",
"host.policy.openHint": "Anyone with the room code can join",
"host.policy.privateHint": "Invite link only, or set a room password",
"host.policy.privateHint": "Join with an invite link, or set a room password to allow code entry",
"host.policy.privatePasswordHint": "Join with an invite link, or the room code and password",
"host.policy.setOpen": "Room is now open",
"host.policy.setPrivateInvite": "Room is now private, invite link only",
"host.policy.setPrivatePassword": "Room is now private, invite link or password",
@@ -215,7 +217,6 @@ export const en: Record<CopyKey, string> = {
"host.accessFailed": "Cannot update room settings right now; try again later",
"host.viewers.empty": "No viewers yet",
"host.viewers.waiting": "Waiting for friends to join",
"host.viewerOverview": "All viewer connections",
"host.quality": "Video preset",
@@ -242,14 +243,14 @@ export const en: Record<CopyKey, string> = {
"host.advanced.route.topoHint": "Gradually pick healthier paths while live",
"host.advanced.route.peerOnly": "Privacy mode",
"host.advanced.route.peerOnlyHint":
"Use P2P routes only; viewers without a P2P path cannot watch",
"Share over peer-to-peer (P2P) connections only. Viewers cannot watch if those connections fail",
"host.advanced.route.peerOnlyRequired":
"This instance has no media-server fallback, so Privacy mode stays on; viewers need a P2P path",
"This service supports peer-to-peer (P2P) media only, so Privacy mode is always on",
"host.advanced.route.natPrediction": "NAT traversal",
"host.advanced.route.natPredictionHint":
"Use self-hosted STUN for a bounded set of extra direct attempts; ordinary ICE is unchanged",
"Use extra STUN observations to try more direct paths",
"host.advanced.route.natPredictionUnavailable":
"Extra NAT traversal is not enabled on this instance; ordinary P2P still works",
"Extra NAT traversal is not enabled on this service; ordinary P2P remains available",
"host.advanced.codec": "Video codec",
"host.advanced.codec.auto": "Auto",
"host.advanced.codec.autoHint": "Pick automatically",
@@ -267,17 +268,12 @@ export const en: Record<CopyKey, string> = {
"state.signal.connecting": "Connecting",
"state.signal.reconnecting": "Recovering",
"state.signal.offline": "Not connected to server",
"state.presence.online": "Online",
"state.presence.offline": "Offline",
"state.peer.connected": "Connected",
"state.peer.connecting": "Connecting",
"state.peer.routing": "Routing",
"state.peer.waiting": "Waiting for share",
"state.peer.reconnecting": "Recovering",
"state.peer.failed": "Connection failed",
"state.peer.disconnected": "Connection lost",
"state.peer.closed": "Closed",
"state.peer.new": "Preparing",
"state.route.p2p": "P2P direct",
"state.route.sfu": "Via server",
@@ -293,7 +289,7 @@ export const en: Record<CopyKey, string> = {
"stats.rtt": "RTT",
"stats.codec": "Video codec",
"stats.jitter": "Jitter",
"stats.dropped": "Dropped frames",
"stats.dropped": "Dropped frames (interval)",
"stats.encoder": "Encoder",
"stats.audio": "Audio bitrate",
"stats.unknown": "Unknown",
@@ -345,7 +341,10 @@ export const en: Record<CopyKey, string> = {
"viewer.msg.denied": "This room cannot be joined by code right now",
"viewer.msg.invalidInvite": "Invite link is no longer valid",
"viewer.msg.closed": "Room closed",
"viewer.msg.full": "Cannot join the room right now",
"viewer.msg.full": "Room is full",
"viewer.msg.accessFailed": "Unable to join the room",
"viewer.hint.accessFailed": "Reload this page to try again, or use an invite link from the host.",
"viewer.hint.full": "Try again when a seat opens up.",
"viewer.msg.stale": "Page updated; refresh to continue",
"viewer.msg.sessionReplaced": "This page's session was taken over by another tab",
"viewer.msg.signalTerminated": "Connection terminated; refresh to continue",
@@ -356,8 +355,6 @@ export const en: Record<CopyKey, string> = {
"viewer.hint.notFound": "Check the room code, or ask the host for a new invite link.",
"viewer.hint.invite": "Ask the host for a new invite link.",
"viewer.hint.generic": "Check your entry point and try again.",
"viewer.error.p2p": "P2P media connection error",
"viewer.error.relay": "Downstream media connection error",
"mode.language": "Interface language",
"mode.more": "More languages",
@@ -367,10 +364,6 @@ export const en: Record<CopyKey, string> = {
"theme.dark.short": "Dark",
"theme.light.short": "Light",
"story.room": "Room found",
"story.link": "Linking up",
"story.show": "Receiving picture",
"gate.connectFailed": "Cannot reach the site access service; try again",
"gate.expired": "Site access expired; verify again",
"gate.serviceUnavailable": "Site access service unavailable ({status})",
@@ -395,8 +388,6 @@ export const en: Record<CopyKey, string> = {
"host.fail.start": "Failed to start sharing",
"host.err.createConnection": "Failed to create the connection",
"host.err.codecUnsupported": "This browser cannot use a supported video codec",
"host.err.applySender": "Failed to apply sender parameters",
"host.err.applyAudioSender": "Failed to apply audio sender parameters",
"host.fail.source": "Failed to switch share source",
"host.fail.quality": "Failed to apply quality settings",
"host.fail.connection": "Viewer connection handling failed",
@@ -414,8 +405,8 @@ export const en: Record<CopyKey, string> = {
"host.capture.noSource": "No shareable screen source available",
"host.capture.readFailed": "The browser cannot read the selected source right now",
"host.capture.unavailable": "This page cannot start screen sharing",
"host.notice.reconnecting": "Some viewers are reconnecting",
"host.notice.sfuRecovering": "SFU source switch failed; restoring viewer connections",
"host.notice.reconnecting": "some viewers are reconnecting",
"host.notice.sfuRecovering": "Server forwarding source switch failed; restoring viewer connections",
"host.password.inputPlaceholder": "Enter password",
"stats.warn.codec": "Video codec is {codec}; expected H264 or VP8",
@@ -425,10 +416,6 @@ export const en: Record<CopyKey, string> = {
"viewer.stageAria": "Shared picture",
"host.warn.audioUnread": "The browser did not report the audio bitrate ceiling",
"host.warn.bandwidth": "This connection is bandwidth-limited; quality was reduced automatically",
"host.warn.encoding": "Encoding performance is limited; quality was reduced automatically",
"host.warn.other": "The browser keeps reporting other quality limits",
"host.warn.unclassified": "The browser keeps reporting an unclassified quality limit",
"host.warn.audioRewritten": "The browser rewrote the audio bitrate ceiling to {kbps} kbps",
"host.warn.senderPartial": "The browser did not fully accept {params}",
"host.warn.param.maxBitrate": "bitrate ceiling",
@@ -436,16 +423,16 @@ export const en: Record<CopyKey, string> = {
"host.warn.param.scaleResolutionDownBy": "resolution scaling",
"host.warn.param.degradationPreference": "quality preference",
"host.warn.param.scalabilityMode": "scalability mode",
"host.warn.sfuRecover": "SFU {params} failed; automatic recovery started",
"host.warn.sfuRecover": "Server forwarding: {params} failed; automatic recovery started",
"host.warn.sfuStage.connect": "connect",
"host.warn.sfuStage.source": "source",
"host.warn.sfuStage.videoPublish": "video publish",
"host.warn.sfuStage.senderConfig": "sender config",
"host.warn.sfuStage.audioPublish": "audio publish",
"host.warn.sfuStage.transport": "transport",
"host.fail.sfuSwitch": "Failed to switch the SFU share source",
"host.fail.sfuAudioParams": "Failed to apply SFU audio sender parameters",
"host.fail.sfuParams": "Failed to apply SFU sender parameters",
"host.fail.sfuSwitch": "Failed to switch the server forwarding source",
"host.fail.sfuAudioParams": "Failed to apply audio parameters for server forwarding",
"host.fail.sfuParams": "Failed to apply sender parameters for server forwarding",
};
export const enTitleFrames = {
@@ -501,6 +488,7 @@ export const enTitleFrames = {
"Quiet little room",
],
viewerReady: ["Press Play"],
viewerClosed: [en["viewer.msg.closed"]],
viewerUnavailable: ["Unavailable"],
hostUnavailable: [en["host.fail.start"]],
viewerWaiting: [
+7 -6
View File
@@ -31,7 +31,7 @@ export const visualTitleFrames = {
"📺 🪄",
],
paused: ["⏸️"],
hostStarting: ["⏳", "⏳ 🔗", "⏳ 📶"],
hostStarting: ["⏳", "⏳ 📺"],
hostEnded: ["⏹️"],
hostReady: [
"🛋️",
@@ -45,15 +45,16 @@ export const visualTitleFrames = {
],
hostIdle: [
"💤",
"📺 💤",
"💤 📺",
"💤 🌙",
"📺 🌙",
"📺 ☁️",
"🛋️ 💤",
"📺 🫧",
"💤 💤",
"💤 ☁️",
"💤 🛋️",
"💤 🫧",
"💤 ✨",
],
viewerReady: ["▶️"],
viewerClosed: ["⏹️"],
viewerUnavailable: ["❗"],
hostUnavailable: ["❗"],
viewerWaiting: [
+27 -39
View File
@@ -1,5 +1,6 @@
// Canonical catalog: add keys here first; every other locale must match it.
export const zh = {
"common.loading": "正在加载",
"playback.controls": "本机播放控制",
"playback.play": "播放",
"playback.pause": "暂停本机播放",
@@ -85,9 +86,9 @@ export const zh = {
"client.launch.go": "进入 Piik",
"client.launch.starting": "正在准备 Piik",
"client.launch.error": "Piik 启动失败",
"client.launch.reopen": "请查看上方原因和黑窗口中的详细信息,关闭并重新打开 Piik 后重试。需要记录日志时,可先开启「诊断启动」。",
"client.launch.reopen": "请查看上方原因和 Piik 终端中的详细信息,关闭并重新打开 Piik 后重试。需要记录日志时,可先开启「诊断启动」。",
"client.launch.debug": "诊断启动",
"client.launch.debugHint": "本次启动记录 App 和网页诊断。App 日志可在黑窗口按 D 导出。",
"client.launch.debugHint": "本次启动记录 App 和网页诊断。App 日志可在终端按 D 导出。",
"client.launch.loadFailed": "无法读取启动设置,请重试",
"client.update.available": "发现新的 Piik 版本",
"client.update.differentBuild": "当前构建与官方发行版不同",
@@ -156,7 +157,7 @@ export const zh = {
"host.roomReplaced": "房间号已更换",
"host.roomInvalid": "房间已失效,再次点击将创建新房",
"host.roomReplace": "更换房间号",
"host.roomReplaceConfirm": "确认更换房间号",
"host.roomReplaceConfirm": "确认更换房间号:当前分享会结束,观众需要重新加入",
"host.terminated.stale": "页面版本已更新,请刷新后重试",
"host.terminated.session": "此页面的会话已被另一个标签页接管",
"host.terminated.signal": "服务器连接已终止,请刷新后重试",
@@ -171,14 +172,14 @@ export const zh = {
"host.nameSave": "保存昵称",
"host.nameCancel": "取消编辑昵称",
"host.nameEdit": "编辑昵称",
"host.nameError": "名称格式无效或超过 24 个字符",
"host.nameOffline": "开始分享并连接后才能修改昵称",
"host.nameError": "昵称格式无效或超过 24 个字符",
"host.nameOffline": "昵称已保存,连接恢复后同步给其他人",
"host.invite": "邀请链接",
"host.invite.copy": "复制邀请链接",
"host.invite.copyFailed": "无法复制邀请链接,请稍后重试",
"host.invite.rotate": "更新邀请链接",
"host.invite.revoke": "撤销邀请链接",
"host.invite.rotate": "更新邀请链接;使用旧链接的观众需要重新加入",
"host.invite.revoke": "撤销邀请链接,并断开通过该链接加入的观众",
"host.invite.rotateShort": "更新链接",
"host.invite.revokeShort": "撤销链接",
"host.invite.updated": "邀请链接已更新",
@@ -191,10 +192,11 @@ export const zh = {
"host.policy.open": "公开",
"host.policy.private": "私密",
"host.policy.currentOpen": "房间号可加入",
"host.policy.currentPassword": "密码可加入",
"host.policy.currentPassword": "凭房间号和密码加入",
"host.policy.currentInvite": "仅限邀请",
"host.policy.openHint": "知道房间号即可加入",
"host.policy.privateHint": "凭邀请链接加入,或设置房间密码",
"host.policy.privateHint": "凭邀请链接加入;设置密码后也可凭房间号加入",
"host.policy.privatePasswordHint": "凭邀请链接,或房间号和密码加入",
"host.policy.setOpen": "房间已设为公开",
"host.policy.setPrivateInvite": "房间已设为私密,仅限邀请加入",
"host.policy.setPrivatePassword": "房间已设为私密,可凭邀请或密码加入",
@@ -213,7 +215,6 @@ export const zh = {
"host.accessFailed": "当前无法更新房间设置,请稍后重试",
"host.viewers.empty": "暂无观众",
"host.viewers.waiting": "等待朋友加入",
"host.viewerOverview": "全员连接数据",
"host.quality": "视频预设",
@@ -240,14 +241,14 @@ export const zh = {
"host.advanced.route.topoHint": "观看中逐步选择更健康的连接",
"host.advanced.route.peerOnly": "隐私模式",
"host.advanced.route.peerOnlyHint":
"仅使用 P2P 路线,不经过媒体服务器;无可用 P2P 路线时无法观看",
"仅通过设备间连接(P2P)传输画面。连接不通时,观众将无法观看",
"host.advanced.route.peerOnlyRequired":
"当前实例未启用媒体服务器兜底,隐私模式保持开启;观众需要可用的 P2P 路线",
"当前服务仅支持设备间连接(P2P),隐私模式无法关闭",
"host.advanced.route.natPrediction": "NAT 穿透",
"host.advanced.route.natPredictionHint":
"使用自建 STUN 增加有限的直连尝试;普通连接不受影响",
"借助额外 STUN 探测,尝试更多直连路径",
"host.advanced.route.natPredictionUnavailable":
"当前实例未启用额外 NAT 穿透;普通 P2P 连接仍可使用",
"当前服务未启用额外 NAT 穿透,仍可使用普通 P2P 连接",
"host.advanced.codec": "视频编码",
"host.advanced.codec.auto": "自动",
"host.advanced.codec.autoHint": "自动选择",
@@ -265,17 +266,12 @@ export const zh = {
"state.signal.connecting": "正在连接",
"state.signal.reconnecting": "正在恢复",
"state.signal.offline": "未连接服务器",
"state.presence.online": "在线",
"state.presence.offline": "离线",
"state.peer.connected": "已连接",
"state.peer.connecting": "正在连接",
"state.peer.routing": "线路分配中",
"state.peer.waiting": "等待开始分享",
"state.peer.reconnecting": "正在恢复",
"state.peer.failed": "连接失败",
"state.peer.disconnected": "连接中断",
"state.peer.closed": "已关闭",
"state.peer.new": "准备中",
"state.route.p2p": "P2P 直连",
"state.route.sfu": "服务器中转",
@@ -291,7 +287,7 @@ export const zh = {
"stats.rtt": "RTT",
"stats.codec": "视频编码",
"stats.jitter": "网络抖动",
"stats.dropped": "丢帧",
"stats.dropped": "本次采样丢帧",
"stats.encoder": "编码器",
"stats.audio": "音频码率",
"stats.unknown": "未知",
@@ -307,7 +303,7 @@ export const zh = {
"stats.encodeMs": "编码耗时/帧",
"stats.decodeMs": "解码耗时/帧",
"state.peer.stuttering": "最近画面卡顿",
"stats.freezeCount": "画面冻结",
"stats.freezeCount": "卡顿次数",
"stats.freezeDuration": "冻结时长",
"stats.audioLoss": "音频丢包率",
"stats.audioJitter": "音频抖动",
@@ -343,7 +339,10 @@ export const zh = {
"viewer.msg.denied": "当前无法通过房间号加入",
"viewer.msg.invalidInvite": "邀请链接无效或已失效",
"viewer.msg.closed": "房间已关闭",
"viewer.msg.full": "当前无法加入房间",
"viewer.msg.full": "房间已满",
"viewer.msg.accessFailed": "暂时无法加入房间",
"viewer.hint.accessFailed": "刷新页面后重试,或使用房主发来的邀请链接。",
"viewer.hint.full": "等有空位后再试。",
"viewer.msg.stale": "页面版本已更新,请刷新后重试",
"viewer.msg.sessionReplaced": "此页面的会话已被另一个标签页接管",
"viewer.msg.signalTerminated": "连接已终止,请刷新后重试",
@@ -354,8 +353,6 @@ export const zh = {
"viewer.hint.notFound": "请确认房间号,或让房主重新发送邀请链接。",
"viewer.hint.invite": "请让房主重新发送邀请链接。",
"viewer.hint.generic": "请检查入口后重试。",
"viewer.error.p2p": "P2P 媒体连接异常",
"viewer.error.relay": "下游媒体连接异常",
"mode.language": "界面语言",
"mode.more": "更多语言",
@@ -365,10 +362,6 @@ export const zh = {
"theme.dark.short": "暗色",
"theme.light.short": "浅色",
"story.room": "找到房间",
"story.link": "建立连接",
"story.show": "接收画面",
"gate.connectFailed": "无法连接站点访问服务,请重试",
"gate.expired": "站点访问已失效,请重新验证",
"gate.serviceUnavailable": "站点验证服务暂时不可用 ({status})",
@@ -393,8 +386,6 @@ export const zh = {
"host.fail.start": "启动分享失败",
"host.err.createConnection": "创建连接失败",
"host.err.codecUnsupported": "当前浏览器无法使用支持的视频编码",
"host.err.applySender": "应用发送参数失败",
"host.err.applyAudioSender": "应用音频发送参数失败",
"host.fail.source": "切换分享来源失败",
"host.fail.quality": "应用画质设置失败",
"host.fail.connection": "观看连接处理失败",
@@ -413,7 +404,7 @@ export const zh = {
"host.capture.readFailed": "浏览器暂时无法读取所选分享来源",
"host.capture.unavailable": "当前页面无法启动屏幕分享",
"host.notice.reconnecting": "部分观众正在重新连接",
"host.notice.sfuRecovering": "SFU 分享来源未切换成功,正在恢复观看连接",
"host.notice.sfuRecovering": "服务器中转的画面来源未切换成功,正在恢复观看连接",
"host.password.inputPlaceholder": "输入密码",
"stats.warn.codec": "视频编码为 {codec},预期 H264 或 VP8",
@@ -423,10 +414,6 @@ export const zh = {
"viewer.stageAria": "共享画面",
"host.warn.audioUnread": "浏览器未读回音频码率上限",
"host.warn.bandwidth": "当前连接带宽受限,画质已自动降低",
"host.warn.encoding": "编码性能受限,画质已自动降低",
"host.warn.other": "浏览器持续报告其他画质限制",
"host.warn.unclassified": "浏览器持续报告未分类的画质限制",
"host.warn.audioRewritten": "浏览器将音频码率上限改写为 {kbps} kbps",
"host.warn.senderPartial": "浏览器未完整接受{params}",
"host.warn.param.maxBitrate": "码率上限",
@@ -434,16 +421,16 @@ export const zh = {
"host.warn.param.scaleResolutionDownBy": "分辨率缩放",
"host.warn.param.degradationPreference": "质量优先级",
"host.warn.param.scalabilityMode": "伸缩模式",
"host.warn.sfuRecover": "SFU {params}失败,已启动自动恢复",
"host.warn.sfuRecover": "服务器中转{params}失败,已启动自动恢复",
"host.warn.sfuStage.connect": "连接",
"host.warn.sfuStage.source": "分享源",
"host.warn.sfuStage.videoPublish": "视频发布",
"host.warn.sfuStage.senderConfig": "视频参数配置",
"host.warn.sfuStage.audioPublish": "音频发布",
"host.warn.sfuStage.transport": "传输",
"host.fail.sfuSwitch": "切换 SFU 分享来源失败",
"host.fail.sfuAudioParams": "应用 SFU 音频发送参数失败",
"host.fail.sfuParams": "应用 SFU 发送参数失败",
"host.fail.sfuSwitch": "切换服务器中转的画面来源失败",
"host.fail.sfuAudioParams": "应用服务器中转的音频参数失败",
"host.fail.sfuParams": "应用服务器中转的发送参数失败",
} as const;
export type CopyKey = keyof typeof zh;
@@ -501,6 +488,7 @@ export const zhTitleFrames = {
"小客厅静悄悄",
],
viewerReady: ["点击播放"],
viewerClosed: [zh["viewer.msg.closed"]],
viewerUnavailable: ["暂不可用"],
hostUnavailable: [zh["host.fail.start"]],
viewerWaiting: [
@@ -9,6 +9,10 @@ import {
videoQualitySettingsEqual, type QualityProfile,
} from "./quality";
// Leave time for ordinary encoding before the outer Viewer/route deadline.
// Bound local transport setup, not frame production from a quiet source.
const LOCAL_CONNECTION_TIMEOUT_MS = 8_000;
export class BrowserEncodingProducer {
readonly id = createOpaqueId();
private send: RTCPeerConnection | null = null;
@@ -23,6 +27,7 @@ export class BrowserEncodingProducer {
private paused: boolean;
private budget: number;
private startupPending: boolean;
private startupTimer: ReturnType<typeof setTimeout> | undefined;
private applied: { capture: QualityProfile; sender: QualityProfile } | null = null;
constructor(
@@ -80,6 +85,9 @@ export class BrowserEncodingProducer {
for (const connection of [send, receive]) {
connection.onconnectionstatechange = () => {
if (connection.connectionState === "failed") this.fail();
else if (send.connectionState === "connected" && receive.connectionState === "connected") {
this.clearStartupTimer();
}
};
}
const forward = (from: RTCPeerConnection, to: RTCPeerConnection) => {
@@ -97,6 +105,9 @@ export class BrowserEncodingProducer {
};
};
const flushReceive = forward(send, receive), flushSend = forward(receive, send);
this.startupTimer = setTimeout(() => {
this.fail(new DOMException("Local encoding connection timed out", "TimeoutError"));
}, LOCAL_CONNECTION_TIMEOUT_MS);
await this.serialize(() => this.applyCurrent());
await send.setLocalDescription(await send.createOffer());
this.checkAlive();
@@ -154,6 +165,7 @@ export class BrowserEncodingProducer {
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.clearStartupTimer();
debugEvent("encoding-pool", "producer-retired", { producerId: this.id });
this.source.removeEventListener("ended", this.fail);
this.input?.removeEventListener("ended", this.fail);
@@ -177,6 +189,11 @@ export class BrowserEncodingProducer {
this.onFailure();
};
private clearStartupTimer(): void {
clearTimeout(this.startupTimer);
this.startupTimer = undefined;
}
private checkAlive(): void {
if (this.disposed) throw new DOMException("Browser encoding producer was retired", "AbortError");
}
+3 -3
View File
@@ -41,7 +41,7 @@ export const QUALITY_PROFILES = {
},
} as const satisfies Record<QualityProfileId, QualitySettings>;
const QUALITY_PROFILE_KEYS = {
export const QUALITY_PROFILE_KEYS = {
"720p30": "host.quality.720p30",
"1080p30": "host.quality.1080p30",
"1080p60": "host.quality.1080p60",
@@ -57,7 +57,7 @@ export const QUALITY_RESOLUTIONS = {
{ width: number; height: number; label: string }
>;
const DEGRADATION_PREFERENCE_KEYS = {
export const DEGRADATION_PREFERENCE_KEYS = {
"maintain-resolution": "host.advanced.preference.resolution",
balanced: "host.advanced.preference.balanced",
"maintain-framerate": "host.advanced.preference.framerate",
@@ -170,7 +170,7 @@ export function qualitySettingsLabel(settings: QualitySettings): string {
if (profileId) {
return say(QUALITY_PROFILE_KEYS[profileId]);
}
return `${QUALITY_RESOLUTIONS[settings.resolution].label} ${settings.maxFramerate} · ${(settings.maxBitrate / 1_000_000).toFixed(1)} Mbps · ${say(DEGRADATION_PREFERENCE_KEYS[settings.degradationPreference])}`;
return `${QUALITY_RESOLUTIONS[settings.resolution].label} ${settings.maxFramerate} fps · ${(settings.maxBitrate / 1_000_000).toFixed(1)} Mbps · ${say(DEGRADATION_PREFERENCE_KEYS[settings.degradationPreference])}`;
}
function captureConstraints(profile: QualityProfile): MediaTrackConstraints {
+24
View File
@@ -1,5 +1,29 @@
type ViewerPlaybackElement = Pick<HTMLVideoElement, "srcObject" | "pause">;
export interface RemoteMediaBinding {
stream: MediaStream;
generation: number;
boundAtRevision: number;
videoTrackKey: string;
audioTrackKey: string;
}
// Audio can arrive after a proved video frame. It updates the audio consumer,
// not the picture's generation, frame proof or local playback intent.
export function nextViewerMediaBinding(
current: RemoteMediaBinding | null,
stream: MediaStream,
revision: number,
nextGeneration: number,
): RemoteMediaBinding {
const videoTrackKey = stream.getVideoTracks().map(track => track.id).sort().join(":");
const audioTrackKey = stream.getAudioTracks().map(track => track.id).sort().join(":");
if (current?.stream === stream && current.videoTrackKey === videoTrackKey) {
return current.audioTrackKey === audioTrackKey ? current : { ...current, audioTrackKey };
}
return { stream, generation: nextGeneration, boundAtRevision: revision, videoTrackKey, audioTrackKey };
}
export function prepareViewerPlayback(
video: ViewerPlaybackElement,
stream: MediaStream,
-10
View File
@@ -143,10 +143,6 @@ export interface ViewerPresentation {
hasCurrentFrame: boolean;
hasRetainedFrame: boolean;
failureCode: ViewerFailureCode | null;
connectionState:
| "routing"
| "waiting"
| Exclude<ViewerPresentationState["connection"], "idle">;
}
export const INITIAL_VIEWER_PRESENTATION_STATE: ViewerPresentationState = {
@@ -654,11 +650,5 @@ function presentation(
hasCurrentFrame: currentFrame,
hasRetainedFrame: state.retainedFrame,
failureCode,
connectionState:
state.connection === "idle"
? state.host === "online" || state.host === "paused"
? "routing"
: "waiting"
: state.connection,
};
}
@@ -0,0 +1,65 @@
import {
nextViewerQualityEvidencePresentationExpiryAt,
refreshViewerQualityEvidencePresentation,
retainPresentViewerQualityEvidence,
type ViewerQualityEvidencePresentation,
} from "./viewer-quality-evidence";
// Owns received presentations and their expiry together. Callers still validate
// route/peer authority and choose when to render the immutable snapshot.
export class ViewerQualityEvidenceStore {
private values = new Map<string, ViewerQualityEvidencePresentation>();
private timers = new Map<string, ReturnType<typeof setTimeout>>();
constructor(
private readonly onChange: (
values: ReadonlyMap<string, ViewerQualityEvidencePresentation>,
) => void,
) {}
getSnapshot(): ReadonlyMap<string, ViewerQualityEvidencePresentation> {
return this.values;
}
set(peerId: string, presentation: ViewerQualityEvidencePresentation | null): void {
this.cancelExpiry(peerId);
const changed = (this.values.get(peerId) ?? null) !== presentation;
if (changed) {
this.values = new Map(this.values);
if (presentation === null) this.values.delete(peerId);
else this.values.set(peerId, presentation);
}
if (presentation !== null) {
const nowMs = Date.now();
const expiryAt = nextViewerQualityEvidencePresentationExpiryAt(presentation, nowMs);
if (expiryAt !== null) {
this.timers.set(peerId, setTimeout(() => {
if (this.values.get(peerId) !== presentation) return;
this.set(peerId, refreshViewerQualityEvidencePresentation(presentation));
}, Math.max(0, expiryAt - nowMs)));
}
}
if (changed) this.onChange(this.values);
}
retain(presentPeerIds: ReadonlySet<string>): void {
const retained = retainPresentViewerQualityEvidence(this.values, presentPeerIds);
if (retained === this.values) return;
for (const peerId of this.values.keys()) {
if (!retained.has(peerId)) this.cancelExpiry(peerId);
}
this.values = retained;
this.onChange(this.values);
}
clear(): void {
this.retain(new Set());
}
private cancelExpiry(peerId: string): void {
const timer = this.timers.get(peerId);
if (timer === undefined) return;
clearTimeout(timer);
this.timers.delete(peerId);
}
}
@@ -516,7 +516,6 @@ export function metricsFromQualityEvidence(
? `${metrics.width}x${metrics.height}`
: null,
bitrateKbps: metrics.bitrateKbps,
packetsLost: metrics.packetsLostDelta,
intervalPacketsReceived: metrics.packetsReceivedDelta,
intervalPacketsLost: metrics.packetsLostDelta,
packetLossPercent: packetLossPercentFromDeltas(
@@ -525,7 +524,6 @@ export function metricsFromQualityEvidence(
),
rttMs: metrics.rttMs,
jitterMs: metrics.jitterMs,
framesDropped: metrics.framesDroppedDelta,
intervalFramesDecoded: metrics.framesDecodedDelta,
intervalFramesDropped: metrics.framesDroppedDelta,
intervalDecodeMs: metrics.decodeMsPerFrame,
+4 -8
View File
@@ -105,10 +105,7 @@ export class NativeSenderEdge {
}
this.offerSent = true;
for (const candidate of this.pendingCandidates.splice(0)) {
if (!this.events.sendSignal(this.peerId, candidate)) {
this.dispose();
return false;
}
this.events.sendSignal(this.peerId, candidate);
}
return true;
} catch {
@@ -204,9 +201,8 @@ export class NativeSenderEdge {
this.pendingCandidates.push(payload);
return;
}
if (!this.events.sendSignal(this.peerId, payload)) {
this.dispose();
this.events.onState("failed");
}
// Trickle signaling can be unavailable while media is still healthy.
// As with Browser senders, the actual edge/route owns failure and recovery.
this.events.sendSignal(this.peerId, payload);
}
}
+19 -15
View File
@@ -1,16 +1,17 @@
import { useEffect, useState, type FormEvent } from "react";
import { z } from "zod";
import { BrandLoader, BrandMark } from "../components/living/BrandMark";
import { BrandMark } from "../components/living/BrandMark";
import { Comic } from "../components/living/Comic";
import { Tooltip } from "../components/living/Tooltip";
import { AppHeader } from "../components/living/Header";
import { LauncherForm, type AppMode } from "../components/living/LauncherForm";
import { Btn, Pill } from "../components/living/primitives";
import { Glyph } from "../ui/icons";
import { useCopy, type CopyKey } from "../ui/copy";
import { hasCopyPreference, useCopy, type CopyKey } from "../ui/copy";
import { consoleLanguage } from "../locales";
import { currentThemePreference } from "../ui/theme";
import { clientLaunchURL } from "../lib/session";
import { clientLaunchURL, type ClientLaunchPresentation } from "../lib/session";
import { browserDebugEnabled } from "../lib/debug";
import {
checkReleaseUpdate,
@@ -87,7 +88,14 @@ export function AppLauncherPage() {
(mode === "local" && lan !== undefined && !lan.selected)) return;
setStarting(true);
setError(null);
const presentation = { lang, vis, theme: currentThemePreference() };
const theme = currentThemePreference();
const presentation: ClientLaunchPresentation = {
lang, vis, theme,
explicit: [
...(hasCopyPreference() ? ["copy" as const] : []),
...(theme !== null ? ["theme" as const] : []),
],
};
try {
const response = await fetch("/api/client-launcher/launch", {
method: "POST",
@@ -139,7 +147,7 @@ export function AppLauncherPage() {
<div className="lr-app">
<AppHeader homeHref="/client" diagnosticControl={appDebug === undefined ? null : (
<Btn icon="cpu" title="client.launch.debugHint" cap="client.launch.debug"
pressed={debug} tone={debug ? "on" : undefined} hint="hint-details"
pressed={debug} tone={debug ? "on" : undefined} hint="debug-start"
disabled={appDebug || loading || starting || error !== null}
onClick={() => setDebug((value) => !value)} />
)} />
@@ -149,13 +157,13 @@ export function AppLauncherPage() {
className="lr-loading"
role="status"
aria-label={t(
starting ? "client.launch.starting" : "gate.checking",
starting ? "client.launch.starting" : "common.loading",
)}
>
<BrandLoader />
<Comic kind="signal-connecting" theme="paper" />
{vis ? null : (
<span className="lr-client-launch-status">
{t(starting ? "client.launch.starting" : "gate.checking")}
{t(starting ? "client.launch.starting" : "common.loading")}
</span>
)}
</div>
@@ -167,7 +175,7 @@ export function AppLauncherPage() {
tone="bad"
label={t(error.kind === "load" ? "client.launch.loadFailed" : "client.launch.error")}
alert
comic="warning"
comic="signal-failed"
/>
{error.kind === "launch" ? <>
{error.detail && <p className="lr-client-launch-detail">{error.detail}</p>}
@@ -176,6 +184,7 @@ export function AppLauncherPage() {
icon="refresh"
title="common.refresh"
cap="common.refresh"
hint="page-refresh"
onClick={() => window.location.reload()}
/>}
</div>
@@ -190,12 +199,7 @@ export function AppLauncherPage() {
lan={lan && { ...lan, onChange: (selected) => setLan({ ...lan, selected }) }}
onSubmit={launch}
>
{updateLink &&
(vis ? (
updateLink
) : (
<Tooltip text={updateText}>{updateLink}</Tooltip>
))}
{update && updateLink && <Tooltip kind="update-available" text={vis ? update.version : updateText}>{updateLink}</Tooltip>}
</LauncherForm>
)}
</main>
+22 -21
View File
@@ -1,10 +1,13 @@
import { useRef, useState } from "react";
import { Btn, Chip, NameTag, Pill, SwitchItem } from "../components/living/primitives";
import { RoomChip, RoomAdmissionBadge } from "../components/living/RoomChip";
import { RoomChip, RoomAdmissionBadge, roomAdmission } from "../components/living/RoomChip";
import { CaptureSourcePicker, type NativeSourceList } from "../components/living/CaptureSourcePicker";
import { LedStrip } from "../components/living/Header";
import { StageTv } from "../components/living/Stage";
import { PlaybackControls } from "../components/living/PlaybackControls";
import { QualityPresets } from "../components/living/QualityPresets";
import { RoomCodeInput } from "../components/living/RoomCodeInput";
import type { QualityProfileId } from "../media/quality";
import { Tooltip } from "../components/living/Tooltip";
import { StatusIndicator } from "../components/living/StatusIndicator";
import { deriveParticipantStatus } from "../ui/media-status";
@@ -25,8 +28,9 @@ export function ControlsPreview() {
const { t, lang, vis } = useCopy();
const en = lang === "en";
const [sound, setSound] = useState(true);
const [preset, setPreset] = useState("1080p30");
const [preset, setPreset] = useState<QualityProfileId>("1080p30");
const [policy, setPolicy] = useState<"open" | "private">("open");
const [roomPassword, setRoomPassword] = useState(false);
const [paused, setPaused] = useState(false);
const [passwordVisible, setPasswordVisible] = useState(false);
const [name, setName] = useState("Piik friend");
@@ -63,13 +67,7 @@ export function ControlsPreview() {
</section>
<section id="option-preview" className="cp-card">
<header><span className="cp-number">02</span><h2>{en ? "Pick, toggle, adjust" : "选一个,再拨一下。"}</h2></header>
<div className="lr-tiles" role="group" aria-label={t("host.quality")}>
{(["720p30", "1080p30", "1080p60"] as const).map((value, index) => <Tooltip key={value} kind="hint-quality" text={vis ? undefined : t(`host.quality.${value}`)}>
<button type="button" className={`lr-tile${preset === value ? " is-selected" : ""}`} aria-pressed={preset === value}
aria-label={t(`host.quality.${value}`)} onClick={() => setPreset(value)}>
<Glyph name={["mountain", "balance", "zap"][index]!} size={23} /><small>{value.replace("p", "p · ")}</small>
</button></Tooltip>)}
</div>
<QualityPresets selected={preset} onSelect={setPreset} />
<div className="cp-tools">
<SwitchItem checked={sound} onChange={setSound} label={t("host.sourcePicker.audioOn")} hint="hint-share-audio" />
<SwitchItem checked disabled locked onChange={() => undefined} label={t("host.advanced.route.peerOnly")}
@@ -77,12 +75,19 @@ export function ControlsPreview() {
</div>
<div className="cp-tools">
<span className="lr-toggle" role="group" aria-label={t("host.policy")} data-selected={policy}>
{(["open", "private"] as const).map(value => <Tooltip key={value} kind={value === "open" ? "hint-policy-open" : "hint-policy-private"} text={vis ? undefined : t(`host.policy.${value}`)}>
{(["open", "private"] as const).map(value => <Tooltip key={value} kind={roomAdmission(value, roomPassword).comic}
text={vis ? undefined : `${t(`host.policy.${value}`)} · ${t(value === "private" && roomPassword ? "host.policy.privatePasswordHint" : `host.policy.${value}Hint`)}`}>
<button type="button" className={policy === value ? "is-selected" : undefined} aria-pressed={policy === value}
aria-label={t(`host.policy.${value}`)} onClick={() => setPolicy(value)}>
<Glyph name={value === "open" ? "globe" : "lock"} size={19} />{vis ? null : <span className="lr-cap">{t(`host.policy.${value}`)}</span>}
</button></Tooltip>)}
</span><RoomAdmissionBadge policy={policy} passwordEnabled={false} />
</span>
<SwitchItem checked={roomPassword} onChange={setRoomPassword} label={t("host.password.set")} />
</div>
<div className="cp-tools">
<RoomAdmissionBadge policy="open" passwordEnabled={false} />
<RoomAdmissionBadge policy="private" passwordEnabled />
<RoomAdmissionBadge policy="private" passwordEnabled={false} />
</div>
</section>
<section id="input-preview" className="cp-card">
@@ -92,7 +97,7 @@ export function ControlsPreview() {
autoComplete="off" onChange={event => setName(event.target.value)} /></label>
</div>
<div className="cp-tools">
<label className="lr-input"><Glyph name="key" size={17} /><input type={passwordVisible ? "text" : "password"}
<label className="lr-input is-password"><Glyph name="key" size={17} /><input type={passwordVisible ? "text" : "password"}
placeholder={t("join.password")} aria-label={t("join.password")} aria-invalid={invalid} aria-describedby={invalid ? "preview-input-error" : undefined} autoComplete="off" />
</label>
<Btn icon={passwordVisible ? "eyeOff" : "eye"} title={passwordVisible ? "host.password.hide" : "host.password.show"}
@@ -101,20 +106,16 @@ export function ControlsPreview() {
</div>
{invalid ? <p id="preview-input-error" className="cp-input-error" role="alert">{en ? "That password did not match. Try again." : "密码没对上,再试一次。"}</p> : null}
<div className="cp-tools"><RoomChip roomId={roomCode} onReplace={() => setRoomCode(code => code === "6020" ? "2048" : "6020")} /></div>
<div className="lr-dials-wrap cp-dials"><div className="lr-dials" aria-hidden="true">{[0, 1, 2, 3].map(index =>
<span key={index} className={`lr-dial${dial[index] ? " is-filled" : index === dial.length ? " is-active" : ""}`}>{dial[index] ?? ""}</span>)}</div>
<input value={dial} inputMode="numeric" maxLength={4} autoComplete="off" aria-label={t("join.field")}
onChange={event => setDial(event.target.value.replace(/\D/g, "").slice(0, 4))} />
</div>
<RoomCodeInput value={dial} onChange={setDial} />
</section>
<section id="feedback-preview" className="cp-card">
<header><span className="cp-number">04</span><h2>{en ? "Clear feedback" : "每种反馈,都说清楚。"}</h2></header>
<div className="cp-tools">
<LedStrip state="live" label={t("state.signal.connected")} />
<LedStrip state="busy" label={t("state.signal.connecting")} />
<LedStrip state="bad" label={t("state.signal.offline")} />
<LedStrip state="live" label={t("state.signal.connected")} comic="signal-connected" />
<LedStrip state="busy" label={t("state.signal.connecting")} comic="signal-connecting" />
<LedStrip state="off" label={t("state.signal.offline")} comic="signal-offline" />
</div>
<div className="cp-tools"><Pill icon="check" tone="good" label={t("common.copied")} comic="hint-copy-code" />
<div className="cp-tools"><Pill icon="check" tone="live" label={t("common.copied")} comic="hint-copy-code" />
<Pill icon="lock" label={t("host.advanced.route.peerOnlyRequired")} comic="hint-route-p2p-required" />
<Pill icon="alert" tone="bad" label={t("common.copyFailed")} comic="warning" /></div>
<div className="cp-tools"><StatusIndicator status={deriveParticipantStatus({ upstream: { kind: "peer", peerId: "preview" }, mediaReady: true }, true)} />
File diff suppressed because it is too large. Load diff
+7 -48
View File
@@ -1,6 +1,8 @@
import { useState, type FormEvent } from "react";
import { AppHeader } from "../components/living/Header";
import { Tooltip } from "../components/living/Tooltip";
import { RoomCodeInput, RoomCodeError } from "../components/living/RoomCodeInput";
import { HintComic } from "../components/living/hints";
import { Glyph } from "../ui/icons";
import { useCopy } from "../ui/copy";
import { roomRouteForExplicitEntry } from "../lib/session";
@@ -11,7 +13,6 @@ export function JoinPage() {
// Counts rejected submits so a repeated one still restarts the shake and
// re-announces the alert; a plain boolean would already be true.
const [rejectedAttempt, setRejectedAttempt] = useState(0);
const error = rejectedAttempt > 0;
function join(event: FormEvent<HTMLFormElement>): void {
event.preventDefault();
@@ -40,12 +41,11 @@ export function JoinPage() {
<main className="lr-join">
<form className="lr-join-panel" onSubmit={join} noValidate>
<span
key={rejectedAttempt}
className={`lr-join-door${error ? " is-shake" : ""}`}
className="lr-join-door"
role="img"
aria-label={t("join.title")}
>
<Glyph name="door" size={96} draw="join-door" />
<HintComic kind="hint-join-go" size={280} />
</span>
{vis ? null : (
<div className="lr-access-text">
@@ -53,50 +53,9 @@ export function JoinPage() {
<p>{t("join.hint")}</p>
</div>
)}
<div className="lr-dials-wrap">
<div className="lr-dials" aria-hidden="true">
{[0, 1, 2, 3].map((index) => (
<span
key={index}
className={`lr-dial${roomId[index] ? " is-filled" : index === roomId.length ? " is-active" : ""}`}
>
{roomId[index] ?? ""}
</span>
))}
</div>
<input
value={roomId}
inputMode="numeric"
autoComplete="off"
maxLength={4}
autoFocus
aria-label={t("join.field")}
aria-invalid={error}
onChange={(event) => {
setRoomId(event.target.value.replace(/\D/g, "").slice(0, 4));
setRejectedAttempt(0);
}}
/>
</div>
{error ? (
<>
<span
key={rejectedAttempt}
className="lr-join-error"
role="alert"
aria-label={t("join.invalid")}
>
<Glyph name="x" size={24} />
</span>
{vis ? (
<span className="visually-hidden">{t("join.invalid")}</span>
) : (
<span className="lr-cap" style={{ color: "var(--danger)" }}>
{t("join.invalid")}
</span>
)}
</>
) : null}
<RoomCodeInput value={roomId} rejectedAttempt={rejectedAttempt} autoFocus
onChange={value => { setRoomId(value); setRejectedAttempt(0); }} />
<RoomCodeError attempt={rejectedAttempt} />
<Tooltip kind="hint-join-go" text={vis ? undefined : t("join.submit")}>
{goButton}
</Tooltip>
-59
View File
@@ -1,59 +0,0 @@
import { StageOverlay, StageTv } from "../components/living/Stage";
import { AppHeader } from "../components/living/Header";
import { useCopy } from "../ui/copy";
import type { ComicKind } from "../components/living/Comic";
import type { GlyphName } from "../ui/icons";
const STATES: readonly {
title: string;
comic: ComicKind;
icon: GlyphName;
message: string;
progress?: string;
}[] = [
{ title: "首次连接", comic: "connecting-p2p", icon: "loader", message: "正在连接" },
{ title: "第一次重试", comic: "connecting-p2p", icon: "loader", message: "正在重试", progress: "1/2" },
{ title: "SFU 连接", comic: "connecting-sfu", icon: "loader", message: "正在连接中转" },
{ title: "等待房主", comic: "waiting-for-host", icon: "door", message: "等待房主" },
{ title: "房主暂停", comic: "host-paused", icon: "pause", message: "房主已暂停" },
{ title: "线路恢复", comic: "recovering", icon: "refresh", message: "正在恢复线路" },
{ title: "线路失败", comic: "route-failed", icon: "alert", message: "线路不可用" },
{ title: "播放失败", comic: "playback-failed", icon: "alert", message: "播放失败" },
{ title: "没有声音", comic: "no-audio", icon: "speaker", message: "没有可用音频" },
{ title: "房主离线", comic: "host-offline", icon: "door", message: "房主已离开" },
{ title: "带宽受限", comic: "bandwidth-limited", icon: "gauge", message: "线路受到带宽限制" },
{ title: "编码受限", comic: "encoder-limited", icon: "tv", message: "编码受到限制" },
];
export function OverlayPreviewPage() {
const { vis } = useCopy();
return (
<div className="lr-app">
<AppHeader />
<main className="lr-room lr-overlay-preview">
<header className="lr-overlay-preview-head">
<h1>Overlay preview</h1>
<p>{vis ? "状态漫画与重试提示预览" : "Stage overlay states"}</p>
</header>
<div className="lr-overlay-preview-grid">
{STATES.map((state) => (
<section className="lr-overlay-preview-card" key={state.title}>
<h2>{state.title}</h2>
<StageTv label={state.message}>
<StageOverlay
icon={state.icon}
comic={state.comic}
message={state.message}
progress={state.progress}
spin={state.icon === "loader"}
transition={state.icon === "loader"}
dim
/>
</StageTv>
</section>
))}
</div>
</main>
</div>
);
}
+1 -1
View File
@@ -77,7 +77,7 @@ export function PeoplePreview() {
<span className="cp-sync-caption">{en ? "One person, the same gesture" : "同一个人,同一个小动作"}</span>
<span><PawnSvg color={participantColor(HOST_ID)} identity={HOST_ID} host /><small>{en ? "Viewer view" : "朋友那边"}</small></span>
</div>
<Couch view={view} host={{ key: HOST_ID, name: hostName, online: true, you: view === "host" }}
<Couch view={view} host={{ key: HOST_ID, name: hostName, you: view === "host" }}
entries={entries} selectedKey={selectedViewer?.peerId} onSelect={select} />
</div>
<div className="cp-tools" role="group" aria-label={en ? "Connection example" : "连接示例"}>
+1 -1
View File
@@ -87,7 +87,7 @@ export function PlaybackPreviewPage() {
useEffect(() => {
if (!theaterMode) return;
function exit(event: KeyboardEvent) {
if (event.key === "Escape") setTheaterMode(false);
if (event.key === "Escape" && !event.defaultPrevented) setTheaterMode(false);
}
document.body.classList.add("lr-theater-open");
window.addEventListener("keydown", exit);
+10 -32
View File
@@ -7,8 +7,8 @@ import { StatusIndicator } from "../components/living/StatusIndicator";
import { Pill } from "../components/living/primitives";
import { deriveViewerPresentation } from "../media/viewer-presentation";
import {
deriveHostStatus, deriveParticipantStatus, deriveViewerStatus, peerConnectionStatus, STATUS_CATALOG,
type QualityObservation, type StatusDescriptor,
deriveHostStatus, deriveParticipantStatus, deriveViewerStatus, STATUS_CATALOG,
type StatusDescriptor,
} from "../ui/media-status";
import { Glyph } from "../ui/icons";
import { useCopy } from "../ui/copy";
@@ -28,14 +28,12 @@ function StatusMark({ status }: { status: StatusDescriptor }) {
export function StatusPreviewPage() {
const { t, titleFrames } = useCopy();
const [selected, setSelected] = useState("bandwidth");
const [qualityOverride, setQualityOverride] = useState<QualityObservation | null>(null);
const [selected, setSelected] = useState("playing");
const [reducedMotion, setReducedMotion] = useState(false);
const [couchView, setCouchView] = useState<"host" | "viewer">("viewer");
const scenario = STATUS_SCENARIOS.find((item) => item.id === selected)!;
const presentation = deriveViewerPresentation(scenario.state);
const quality = qualityOverride ?? scenario.quality;
const status = deriveViewerStatus(presentation, scenario.state.signal, quality,
const status = deriveViewerStatus(presentation, scenario.state.signal,
scenario.state.route?.kind === "none" ? null : scenario.state.route?.kind);
const sourceActive = scenario.state.host === "online" || scenario.state.host === "paused";
const readyParticipant = deriveParticipantStatus({
@@ -48,15 +46,14 @@ export function StatusPreviewPage() {
function choose(id: string) {
setSelected(id);
setQualityOverride(null);
}
return (
<div className={`lr-app sp-page${reducedMotion ? " sp-reduced-motion" : ""}`}>
<div className="lr-app sp-page" data-comic-reduced-motion={reducedMotion || undefined}>
<AppHeader homeHref="/__status-preview" />
<main className="sp-main">
<header className="sp-heading">
<div><p className="sp-kicker">PIIK · 状态语义提案</p><h1>同一事实,各有分寸。</h1>
<div><p className="sp-kicker">Piik · 状态语义</p><h1>同一事实,各有分寸。</h1>
<p>标题说正在做什么,顶部说明信令,电视下沿图标说明画面;悬停查看图示与文字说明。</p></div>
<label className="sp-motion"><input type="checkbox" checked={reducedMotion}
onChange={(event) => setReducedMotion(event.target.checked)} />减少动态效果</label>
@@ -100,7 +97,7 @@ export function StatusPreviewPage() {
</StageTv>
<div className="lr-stage-notices" role="status" aria-live="polite">
{status.notice ? <Pill icon={status.notice.icon} label={t(status.notice.labelKey)}
comic={status.notice.tooltip ?? status.notice.comic} tooltipTone={status.notice.tone} /> : null}
comic={(status.notice.tooltip ?? status.notice.comic)!} tone={status.notice.tone} /> : null}
</div>
<div className="sp-couch-preview">
<div className="sp-couch-view" role="group" aria-label="沙发观察视角">
@@ -111,7 +108,7 @@ export function StatusPreviewPage() {
</button>)}
</div>
<Couch view={couchView}
host={{ key: "preview-host", name: "Host", online: sourceActive, you: couchView === "host" }}
host={{ key: "preview-host", name: "Host", you: couchView === "host" }}
entries={[
{ key: "preview-you", name: "You", status: readyParticipant, you: couchView === "viewer", selectable: false },
{ key: "preview-friend", name: "Friend", selectable: false,
@@ -126,21 +123,11 @@ export function StatusPreviewPage() {
<div><h2>{scenario.name}</h2><p>{scenario.note}</p>
<p className="sp-caption">舞台状态:<code>{presentation.stage}</code> · 画面层:<code>{status.overlay?.mode ?? "none"}</code></p>
</div>
<div className="sp-quality-controls" role="group" aria-label="更新当前质量证据">
<p>手动推入一次观测,检查质量提示能否正确恢复:</p>
{([
["带宽受限", { reason: "bandwidth", fresh: true }],
["恢复正常", { reason: "none", fresh: true }],
["旧样本过期", { reason: "bandwidth", fresh: false }],
] as const).map(([label, observation]) => <button className="lr-btn" key={label}
type="button" aria-pressed={qualityOverride?.reason === observation.reason && qualityOverride.fresh === observation.fresh}
onClick={() => setQualityOverride(observation)}>{label}</button>)}
</div>
</section>
<section className="sp-surfaces" aria-label="各显示位置的含义">
{([
["能否联系服务器", "顶部 · 信令连接", status.connection],
["正在播放什么状态", "电视 · 画面与质量", status.television],
["正在播放什么状态", "电视 · 画面状态", status.television],
] as const).map(([name, location, item]) => <article key={name}>
<p className="sp-kicker">{location}</p><h3>{name}</h3><StatusMark status={item} />
<p className="sp-caption">{t(item.labelKey)}</p>
@@ -174,18 +161,9 @@ export function StatusPreviewPage() {
</div>
</section>
<footer className="sp-footnote">
<details>
<summary>原生 WebRTC 状态怎么映射?</summary>
<p>这只说明传输:connected 不证明首帧已呈现;disconnected 可以恢复;closed 是连接退役,不代表新连接失败。</p>
<div className="sp-host-grid">
{(["new", "connecting", "connected", "disconnected", "failed", "closed"] as const).map((state) =>
<article key={state}><code>{state}</code><StatusMark status={peerConnectionStatus(state)} /></article>,
)}
</div>
</details>
<p>这是开发预览:说明固定中文,控件和漫画跟随右上角中 / EN / ✦ 与明暗主题。</p>
<p>Host 人物灯使用已有的新鲜证据,详情仍在观众列表。Viewer 仅使用服务端已确认的媒体就绪事实,不新增全房质量广播。</p>
<a href="/__tooltip-preview">全部操作漫画</a><span> · </span><a href="/__overlay-preview">旧版画面层画廊</a>
<a href="/__tooltip-preview">全部操作漫画与指标</a>
</footer>
</main>
</div>
+56 -3
View File
@@ -1,3 +1,4 @@
import { useState } from "react";
import { AppHeader } from "../components/living/Header";
import { Tooltip } from "../components/living/Tooltip";
import { ControlsPreview } from "./ControlsPreview";
@@ -6,6 +7,9 @@ import { HINT_KINDS, HintComic, isHintKind, type HintKind } from "../components/
import { Comic, type ComicKind } from "../components/living/Comic";
import { COMIC_KINDS, getComicPresentation, type ComicTone, type ComicMotion } from "../components/living/comic-presentation";
import { useCopy, type CopyKey } from "../ui/copy";
import { replaySvgAnimations } from "../ui/animation";
import { MetricCells } from "../components/living/Metrics";
import { EMPTY_METRICS } from "../types";
const EXAMPLES: { label: string; kind: HintKind | ComicKind; tone: ComicTone; motion: ComicMotion; text: CopyKey }[] = [
{ label: "说明 / Neutral", kind: "hint-volume", tone: "off", motion: "demo", text: "playback.volume" },
@@ -23,7 +27,14 @@ function PreviewCard({ kind, tone, motion, label, text }: {
kind: ComicKind | HintKind; tone?: ComicTone; motion?: ComicMotion; label?: string; text?: string;
}) {
const defaults = getComicPresentation(kind);
return <section className="lr-tooltip-preview-card">
const replay = (card: HTMLElement) => {
const scene = card.querySelector(".lr-tooltip-preview-art > svg");
if (scene) replaySvgAnimations(scene);
};
return <section className="lr-tooltip-preview-card"
onPointerEnter={(event) => { if (event.pointerType !== "touch") replay(event.currentTarget); }}
onPointerDown={(event) => { if (event.pointerType === "touch") replay(event.currentTarget); }}
onFocusCapture={(event) => replay(event.currentTarget)}>
<header>
<code>{label ?? kind}</code>
<span className="lr-tooltip-preview-actions">
@@ -46,23 +57,28 @@ function PreviewCard({ kind, tone, motion, label, text }: {
export function TooltipPreviewPage() {
const { t, lang } = useCopy();
const en = lang === "en";
const [reducedMotion, setReducedMotion] = useState(false);
return (
<div className="lr-app">
<div className="lr-app" data-comic-reduced-motion={reducedMotion || undefined}>
<AppHeader />
<main className="lr-room lr-tooltip-preview">
<header className="lr-tooltip-preview-head">
<h1>{en ? "Piik · UI catalogue" : "Piik · UI 控件大全"}</h1>
<p>{en ? "Actual components, sample data. Try mouse, keyboard and touch." : "正式组件,示例数据。鼠标、键盘、触屏都可以试。"}</p>
<p>{en ? "Actual components, sample data. Hover or tap a comic to replay it; use its corner button to open the tooltip." : "正式组件,示例数据。悬停或轻点漫画可重播,角上的按钮可查看实际提示。"}</p>
<label><input type="checkbox" checked={reducedMotion} onChange={event => setReducedMotion(event.target.checked)} />
{en ? "Reduced motion" : "减少动态效果"}</label>
</header>
<nav className="cp-nav" aria-label={en ? "Preview sections" : "预览目录"}>
<a href="#button-preview">{en ? "Buttons" : "按钮"}</a><a href="#option-preview">{en ? "Options" : "选择与开关"}</a>
<a href="#input-preview">{en ? "Inputs" : "输入与房间号"}</a><a href="#feedback-preview">{en ? "Feedback" : "反馈"}</a>
<a href="#people-preview">{en ? "People & connections" : "人物、沙发与连接图"}</a><a href="#source-preview">{en ? "Source picker" : "画面选择"}</a>
<a href="#playback-preview">{en ? "Playback" : "播放栏"}</a><a href="#comic-preview">{en ? "Tooltips & comics" : "提示与漫画"}</a>
<a href="#metrics-preview">{en ? "Metrics" : "连接数据"}</a>
<a href="#welcome-preview">{en ? "20 opening lines" : "20 句开场白"}</a>
<a href="/__status-preview">{en ? "Status gallery" : "完整状态预览"}</a>
</nav>
<ControlsPreview />
<MetricPreview />
<WelcomeLine />
<details id="welcome-preview" className="lr-welcome-catalog" open>
<summary>20 句开场白 / 20 opening lines</summary>
@@ -86,3 +102,40 @@ export function TooltipPreviewPage() {
</div>
);
}
function MetricPreview() {
const { t, lang } = useCopy();
const en = lang === "en";
const [reason, setReason] = useState("other");
const [sendExpanded, setSendExpanded] = useState(true);
const [receiveExpanded, setReceiveExpanded] = useState(true);
const metrics = {
...EMPTY_METRICS,
resolution: "1920x1080", framesPerSecond: 59.8, bitrateKbps: 7200,
packetLossPercent: .2, rttMs: 24, codec: "video/H264",
availableOutgoingKbps: 12000, qualityLimitationReason: reason,
captureWidth: 2560, captureHeight: 1440, captureFramesPerSecond: 60,
mediaSourceFramesPerSecond: 60, encoderImplementation: "Sample encoder",
powerEfficientEncoder: true, intervalEncodeMs: 2.4,
jitterMs: 1.2, intervalFramesDropped: 2, intervalDecodeMs: 1.4,
intervalFreezeCount: 1, intervalFreezeDurationMs: 120,
audioBitrateKbps: 96, audioPacketLossPercent: .1, audioJitterMs: .8,
audioVideoPlayoutDeltaMs: 3, videoJitterBufferDelayMs: 12,
audioJitterBufferDelayMs: 15, audioConcealedSamplesPercent: .2,
intervalAudioConcealmentEvents: 1,
};
return <section id="metrics-preview" className="cp-section">
<h2>{en ? "Connection metrics" : "连接数据"}</h2>
<p>{en ? "Sample observations in the actual detail cells. Sender limits do not describe the receiver's playback."
: "用正式详情控件展示示例观测。发送端的限制不代表观众的播放状态。"}</p>
<label>{t("stats.qualityState")} <select value={reason} onChange={event => setReason(event.target.value)}>
{(["none", "bandwidth", "cpu", "other", "unclassified"] as const).map(value => <option key={value} value={value}>
{t(value === "none" ? "stats.quality.normal" : `stats.quality.${value}`)}
</option>)}
</select></label>
<h3>{en ? "Sending" : "发送"}</h3>
<MetricCells metrics={metrics} direction="send" expanded={sendExpanded} onToggle={setSendExpanded} />
<h3>{en ? "Receiving" : "接收"}</h3>
<MetricCells metrics={metrics} direction="receive" expanded={receiveExpanded} onToggle={setReceiveExpanded} />
</section>;
}
+64 -162
View File
@@ -35,8 +35,8 @@ import {
StageTv,
} from "../components/living/Stage";
import { StatusIndicator } from "../components/living/StatusIndicator";
import { Tooltip } from "../components/living/Tooltip";
import { PlaybackControls } from "../components/living/PlaybackControls";
import { BrandLoader } from "../components/living/BrandMark";
import {
Btn,
FieldCap,
@@ -72,14 +72,13 @@ import {
import {
freshViewerQualityEvidence,
metricsFromQualityEvidence,
nextViewerQualityEvidencePresentationExpiryAt,
presentViewerQualityEvidence,
qualityEvidenceMatchesSnapshot,
reconcileViewerQualityEvidencePresentation,
refreshViewerQualityEvidencePresentation,
type ViewerQualityEvidencePresentation,
ViewerQualityEvidenceReporter,
} from "../media/viewer-quality-evidence";
import { ViewerQualityEvidenceStore } from "../media/viewer-quality-evidence-store";
import { ViewerMessageAuthority } from "../media/viewer-message-authority";
import {
INITIAL_VIEWER_PRESENTATION_STATE,
@@ -89,7 +88,7 @@ import {
type ViewerPresentationAction,
type ViewerRouteKind,
} from "../media/viewer-presentation";
import { prepareViewerPlayback } from "../media/viewer-playback";
import { nextViewerMediaBinding, prepareViewerPlayback, type RemoteMediaBinding } from "../media/viewer-playback";
import {
isAutoplayPolicyRejection,
observeCompositedVideoFrame,
@@ -126,6 +125,7 @@ import { NativeSenderPeer } from "../native/native-sender-peer";
interface ViewerPageProps {
roomId: string;
viewerGrant?: string;
invalidGrant?: boolean;
launchedByClient?: boolean;
}
@@ -151,14 +151,6 @@ interface PendingPeerRoute {
qualityResult: CandidateQualityProbeResult;
}
interface RemoteMediaBinding {
stream: MediaStream;
generation: number;
boundAtRevision: number;
videoTrackKey: string;
audioTrackKey: string;
}
function MeterTag({ icon, label }: { icon: GlyphName; label: string }) {
const { vis } = useCopy();
return (
@@ -172,6 +164,7 @@ function MeterTag({ icon, label }: { icon: GlyphName; label: string }) {
export function ViewerPage({
roomId,
viewerGrant,
invalidGrant = false,
launchedByClient = false,
}: ViewerPageProps) {
const { lang, t, vis, titleFrames } = useCopy();
@@ -199,7 +192,7 @@ export function ViewerPage({
} | null>(null);
const [relaySnapshot, setRelaySnapshot] = useState<PeerSnapshot | null>(null);
const [relayChildEvidence, setRelayChildEvidence] = useState<
Map<string, ViewerQualityEvidencePresentation>
ReadonlyMap<string, ViewerQualityEvidencePresentation>
>(() => new Map());
const [showConnectionDetails, setShowConnectionDetails] = useState(false);
const [showTopology, setShowTopology] = useState(false);
@@ -243,7 +236,7 @@ export function ViewerPage({
useEffect(() => {
if (!theaterMode) return;
const exitOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") setTheaterMode(false);
if (event.key === "Escape" && !event.defaultPrevented) setTheaterMode(false);
};
document.body.classList.add("lr-theater-open");
window.addEventListener("keydown", exitOnEscape);
@@ -317,7 +310,6 @@ export function ViewerPage({
const viewerStatus = deriveViewerStatus(
presentation,
signalStatus,
undefined,
assignedRouteKind,
);
const titleContent = titleFrames(viewerStatus.titleFrameKey).map((frame) =>
@@ -326,9 +318,9 @@ export function ViewerPage({
useDocumentTitle(
[
accessState === "ready" ? roomId : null,
accessState === "ready" ? titleContent[0] : null,
titleContent[0],
],
accessState === "ready" ? titleContent.slice(1) : [],
titleContent.slice(1),
);
const peerConnectionIdentity = peerRef.current?.getConnectionIdentity() ?? null;
const reconnectRoute = viewerReconnectRoute(
@@ -344,34 +336,15 @@ export function ViewerPage({
const routeMetrics = routePresentation.evidence?.metrics ?? null;
function bindRemoteStream(stream: MediaStream, revision: number): void {
const videoTrackKey = stream
.getVideoTracks()
.map((track) => track.id)
.sort()
.join(":");
const audioTrackKey = stream
.getAudioTracks()
.map((track) => track.id)
.sort()
.join(":");
const current = remoteMediaRef.current;
if (
current?.stream === stream &&
current.videoTrackKey === videoTrackKey &&
current.audioTrackKey === audioTrackKey
) {
return;
}
const next: RemoteMediaBinding = {
stream,
boundAtRevision: revision,
videoTrackKey,
audioTrackKey,
generation: ++mediaGenerationRef.current,
};
invalidateQualityPresentation();
const next = nextViewerMediaBinding(current, stream, revision, mediaGenerationRef.current + 1);
if (next === current) return;
const pictureChanged = next.generation !== current?.generation;
mediaGenerationRef.current = next.generation;
remoteMediaRef.current = next;
setRemoteMedia(next);
if (!pictureChanged) return;
invalidateQualityPresentation();
dispatchPresentation({
type: "media-bound",
generation: next.generation,
@@ -517,11 +490,10 @@ export function ViewerPage({
let pendingPeer: PendingPeerRoute | null = null;
const decodedFrameStall = new DecodedFrameStallDetector();
const messageAuthority = new ViewerMessageAuthority();
const relayChildEvidenceCurrent = new Map<
string,
ViewerQualityEvidencePresentation
>();
const relayChildEvidenceTimers = new Map<string, number>();
const relayChildEvidenceStore = new ViewerQualityEvidenceStore((values) => {
if (active) setRelayChildEvidence(values);
});
setRelayChildEvidence(relayChildEvidenceStore.getSnapshot());
let sfuTransportConnected = false;
let nativeClientPromise: Promise<NativeClient | null> | null = null;
let nativeViewerAvailable = true;
@@ -740,58 +712,6 @@ export function ViewerPage({
pendingRouteConnection = null;
}
function commitRelayChildEvidence(
peerId: string,
presentation: ViewerQualityEvidencePresentation | null,
): void {
const timer = relayChildEvidenceTimers.get(peerId);
if (timer !== undefined) {
window.clearTimeout(timer);
relayChildEvidenceTimers.delete(peerId);
}
const current = relayChildEvidenceCurrent.get(peerId) ?? null;
if (presentation === null) {
relayChildEvidenceCurrent.delete(peerId);
} else {
relayChildEvidenceCurrent.set(peerId, presentation);
}
if (current !== presentation) {
setRelayChildEvidence(new Map(relayChildEvidenceCurrent));
}
if (presentation === null) {
return;
}
const nowMs = Date.now();
const expiryAt = nextViewerQualityEvidencePresentationExpiryAt(
presentation,
nowMs,
);
if (expiryAt === null) {
return;
}
const expected = presentation;
relayChildEvidenceTimers.set(
peerId,
window.setTimeout(() => {
relayChildEvidenceTimers.delete(peerId);
if (relayChildEvidenceCurrent.get(peerId) !== expected) {
return;
}
commitRelayChildEvidence(
peerId,
refreshViewerQualityEvidencePresentation(expected),
);
}, Math.max(0, expiryAt - nowMs)),
);
}
function clearRelayChildEvidence(): void {
for (const peerId of [...relayChildEvidenceCurrent.keys()]) {
commitRelayChildEvidence(peerId, null);
}
}
function acceptRelayChildEvidence(evidence: ViewerQualityEvidence): void {
const relaySnapshot =
viewerRelay?.getSnapshot(evidence.viewerPeerId) ?? null;
@@ -803,10 +723,10 @@ export function ViewerPage({
) {
return;
}
commitRelayChildEvidence(
relayChildEvidenceStore.set(
evidence.viewerPeerId,
presentViewerQualityEvidence(
relayChildEvidenceCurrent.get(evidence.viewerPeerId) ?? null,
relayChildEvidenceStore.getSnapshot().get(evidence.viewerPeerId) ?? null,
evidence,
),
);
@@ -892,7 +812,7 @@ export function ViewerPage({
if (active) {
setRelaySnapshot(snapshot);
for (const [peerId, presentation] of [
...relayChildEvidenceCurrent,
...relayChildEvidenceStore.getSnapshot(),
]) {
const reconciled =
reconcileViewerQualityEvidencePresentation(
@@ -900,7 +820,7 @@ export function ViewerPage({
viewerRelay?.getSnapshot(peerId) ?? null,
);
if (reconciled !== presentation) {
commitRelayChildEvidence(peerId, reconciled);
relayChildEvidenceStore.set(peerId, reconciled);
}
}
}
@@ -957,7 +877,7 @@ export function ViewerPage({
(peerId, index) => peerId !== nextChildPeerIds[index],
);
if (changed) {
clearRelayChildEvidence();
relayChildEvidenceStore.clear();
}
const relay = ensureViewerRelay();
if (activeRevision === undefined) {
@@ -1380,7 +1300,7 @@ export function ViewerPage({
function clearPeerState(clearMedia = false): void {
clearUpstreamState(clearMedia);
clearRelayChildEvidence();
relayChildEvidenceStore.clear();
viewerRelay?.stop();
}
@@ -1622,7 +1542,7 @@ export function ViewerPage({
dispatchPresentation({ type: "access", access: "ready" });
setViewerPasswordDraft("");
setViewerPasswordError(null);
clearRelayChildEvidence();
relayChildEvidenceStore.clear();
currentPeerId = message.peerId;
setSelfPeerId(message.peerId);
endpointMediaCopyCapacity = message.endpointMediaCopyCapacity;
@@ -1636,7 +1556,7 @@ export function ViewerPage({
currentRoutePolicy = message.routePolicy;
const nextRouteRevision = message.routeRevision;
if (nextRouteRevision !== currentRouteRevision) {
clearRelayChildEvidence();
relayChildEvidenceStore.clear();
}
currentRouteRevision = nextRouteRevision;
activateRouteIdentity(
@@ -1753,7 +1673,7 @@ export function ViewerPage({
: null;
if (result === "accepted") {
if (message.revision !== currentRouteRevision) {
clearRelayChildEvidence();
relayChildEvidenceStore.clear();
}
activateRouteIdentity(
message.revision,
@@ -1953,20 +1873,12 @@ export function ViewerPage({
return;
}
if (message.type === "error") {
if (
[
"AUTH_REQUIRED",
"INVALID_TOKEN",
"ROOM_NOT_FOUND",
"ROOM_ACCESS_DENIED",
"ROOM_FULL",
].includes(message.code)
) {
const failure = viewerFailureFromServerCode(message.code);
if (failure !== null && failure !== "SERVER_ERROR") {
setAssignedRoute(null);
clearViewerSfuRoute();
clearPeerState(true);
clearParticipantPresence();
const failure = viewerFailureFromServerCode(message.code);
dispatchPresentation({
type: "access",
access: "denied",
@@ -1987,7 +1899,6 @@ export function ViewerPage({
}
return;
}
const failure = viewerFailureFromServerCode(message.code);
if (failure === "SERVER_ERROR") {
dispatchPresentation({ type: "server-error" });
}
@@ -2007,7 +1918,7 @@ export function ViewerPage({
if (qualityEvidenceReporterRef.current === qualityEvidenceReporter) {
qualityEvidenceReporterRef.current = null;
}
clearRelayChildEvidence();
relayChildEvidenceStore.clear();
signal.stop();
clearParticipantPresence();
if (signalRef.current === signal) {
@@ -2047,7 +1958,7 @@ export function ViewerPage({
) {
attemptPlayback(video, remoteMedia);
}
}, [remoteMedia]);
}, [remoteMedia?.generation]);
useEffect(() => {
const paused = presentationState.host === "paused";
@@ -2070,7 +1981,7 @@ export function ViewerPage({
if (shouldResume && video && remoteMedia) {
attemptPlayback(video, remoteMedia);
}
}, [presentationState.host, remoteMedia]);
}, [presentationState.host, remoteMedia?.generation]);
useEffect(() => {
const video = videoRef.current;
@@ -2108,7 +2019,7 @@ export function ViewerPage({
mediaProofEpoch,
mediaProofGeneration,
presentationState.host,
remoteMedia,
remoteMedia?.generation,
]);
function retryConnection(): void {
@@ -2161,10 +2072,14 @@ export function ViewerPage({
if (accessState !== "ready") {
const failureCode = presentation.failureCode;
const missingCredential = failureCode === "INVALID_TOKEN" && !viewerGrant && !invalidGrant;
const malformedInvite = invalidGrant && (failureCode === "INVALID_TOKEN" || failureCode === "ROOM_ACCESS_DENIED");
const deniedMessageKey = malformedInvite ? "viewer.msg.invalidInvite"
: missingCredential ? "viewer.msg.accessFailed" : presentation.messageKey;
const codeOnlyDenied =
!viewerGrant && failureCode === "ROOM_ACCESS_DENIED";
const canRefresh =
failureCode === null ||
failureCode === null || missingCredential ||
// ROOM_FULL is transient by nature: a seat frees when a Viewer leaves,
// so the hint that says "try again" needs something to try.
[
@@ -2174,18 +2089,12 @@ export function ViewerPage({
"SESSION_REPLACED",
"SIGNAL_TERMINATED",
].includes(failureCode);
const deniedComic: ComicKind =
failureCode === "ROOM_FULL"
? "room-full"
: codeOnlyDenied
? "access-denied"
: failureCode === "INVALID_TOKEN"
? "invalid-invite"
: failureCode === "ROOM_NOT_FOUND" ||
failureCode === "ROOM_CLOSED"
? "room-not-found"
: "warning";
const deniedHintKey: CopyKey = codeOnlyDenied
const deniedComic: ComicKind = malformedInvite ? "invalid-invite"
: missingCredential ? "access-denied" : viewerStatus.activity.comic ?? "signal-failed";
const deniedHintKey: CopyKey = malformedInvite ? "viewer.hint.invite"
: missingCredential ? "viewer.hint.accessFailed"
: failureCode === "ROOM_FULL" ? "viewer.hint.full"
: codeOnlyDenied
? "viewer.hint.denied"
: failureCode === "ROOM_NOT_FOUND" ||
failureCode === "ROOM_CLOSED"
@@ -2200,7 +2109,7 @@ export function ViewerPage({
<AppHeader
led={
accessState === "checking" ? (
<LedStrip state="busy" label={t(presentation.messageKey)} />
<LedStrip state="busy" label={t(presentation.messageKey)} comic="signal-connecting" />
) : undefined
}
/>
@@ -2210,23 +2119,21 @@ export function ViewerPage({
<span
className="lr-viewer-entry-brand"
role="status"
aria-label={t(presentation.messageKey)}
style={{ display: "grid", justifyItems: "center", gap: 14 }}
>
<BrandLoader />
<Comic kind="signal-connecting" theme="paper" />
<span className={vis ? "visually-hidden" : "lr-tv-msg"}>{t(presentation.messageKey)}</span>
</span>
{vis ? null : (
<span className="lr-tv-msg">{t(presentation.messageKey)}</span>
)}
</div>
) : (
<div className="lr-join-panel">
<Comic kind={deniedComic} theme="paper" tone={viewerStatus.activity.tone} />
<span className="visually-hidden" role="alert">
{t(presentation.messageKey)} · {t(deniedHintKey)}
{t(deniedMessageKey)} · {t(deniedHintKey)}
</span>
{vis ? null : (
<div className="lr-access-text">
<h1>{t(presentation.messageKey)}</h1>
<h1>{t(deniedMessageKey)}</h1>
<p>{t(deniedHintKey)}</p>
</div>
)}
@@ -2247,7 +2154,7 @@ export function ViewerPage({
style={{ display: "grid", justifyItems: "center", gap: 14 }}
onSubmit={submitViewerPassword}
>
<span className="lr-input" style={{ minWidth: 220 }}>
<span className="lr-input is-password">
<Glyph name="key" size={17} />
<input
type="password"
@@ -2283,6 +2190,7 @@ export function ViewerPage({
icon="refresh"
cap="common.refresh"
title="common.refresh"
hint="page-refresh"
onClick={() => window.location.reload()}
/>
)}
@@ -2343,7 +2251,7 @@ export function ViewerPage({
return {
key: viewer.peerId,
name: viewer.label,
status: deriveParticipantStatus(viewer, presentationState.host === "online"),
status: deriveParticipantStatus(viewer, presentationState.host === "online" || presentationState.host === "paused"),
you: isSelf,
selectable: isChild ? undefined : false,
};
@@ -2359,12 +2267,6 @@ export function ViewerPage({
return (
<div className="lr-app">
<style>{`
/* The video fills the screen exactly, so its own :focus-visible outline is
clipped by .lr-tv-screen's overflow: hidden. Ring the screen container
instead; :focus-visible keeps mouse clicks ring-free like every control. */
.lr-tv-screen:has(:focus-visible) { outline: 3px solid var(--action); outline-offset: 2px; }
`}</style>
<AppHeader
led={
<LedStrip
@@ -2382,7 +2284,7 @@ export function ViewerPage({
</h1>
<div className="lr-scene" id="viewer-stage">
<StageTv
live={presentation.stage === "playing"}
live={presentation.hasCurrentFrame || presentation.hasRetainedFrame}
label={t("viewer.stageAria")}
indicator={<StatusIndicator status={viewerStatus.television}
label={retryAttempt ? stageMessage : undefined} />}
@@ -2468,8 +2370,9 @@ export function ViewerPage({
<Pill
icon={viewerStatus.notice.icon}
label={t(viewerStatus.notice.labelKey)}
comic={viewerStatus.notice.comic}
tooltipTone={viewerStatus.notice.tone}
comic={(viewerStatus.notice.comic ?? viewerStatus.notice.tooltip)!}
tone={viewerStatus.notice.tone}
motion={viewerStatus.notice.pulse ? "progress" : "still"}
/>
)}
</div>
@@ -2480,7 +2383,6 @@ export function ViewerPage({
? {
key: labeledHostPresence.peerId,
name: labeledHostPresence.label,
online: true,
}
: null
}
@@ -2499,13 +2401,11 @@ export function ViewerPage({
</div>
<div
className="lr-row-group lr-viewer-host-slot"
aria-label={t(
hostDisplayName ? "viewer.title" : "viewer.titleFallback",
hostDisplayName ? { name: hostDisplayName } : undefined,
)}
>
<Glyph name="tv" size={17} />
<b>{hostDisplayName ?? t("common.host")}</b>
<Tooltip overflow={{ text: hostDisplayName ?? t("common.host"), selector: "b" }} className="lr-name-hint">
<b>{hostDisplayName ?? t("common.host")}</b>
</Tooltip>
</div>
<div className="lr-viewer-personal-controls">
<form
@@ -2534,6 +2434,7 @@ export function ViewerPage({
/>
</span>
<Btn
key="save-name"
icon="check"
title="host.nameSave"
hint="hint-rename"
@@ -2558,6 +2459,7 @@ export function ViewerPage({
identity={selfPeerId ?? viewerClientId}
/>
<Btn
key="edit-name"
icon="pencil"
cap="common.edit"
title="host.nameEdit"
@@ -2576,7 +2478,7 @@ export function ViewerPage({
tone="bad"
label={t("host.nameError")}
alert
comic="warning"
comic="name-invalid"
/>
)}
</form>
-2
View File
@@ -13,8 +13,6 @@
.cp-tools .lr-pill { max-width: 100%; }
.cp-feedback { min-height: 2em; }
.cp-card .cp-input-error { color: var(--danger); }
.cp-dials { width: fit-content; max-width: 100%; margin: 20px 0 0; }
.cp-dials .lr-dial { width: 46px; height: 58px; font-size: 28px; }
.cp-section { margin-top: 44px; scroll-margin-top: 24px; }
.cp-people-room { padding: 24px 12px 8px; border-radius: 26px; background: var(--wall-2); }
.cp-sync { display: flex; align-items: center; justify-content: center; gap: 24px; }
Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff. Show more