mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-08-05 21:13:52 +08:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8eac31f69f | ||
|
|
9653e16a68 | ||
|
|
d0c0852fbb | ||
|
|
c0a0c90cbd | ||
|
|
9e3483b836 | ||
|
|
132f445c29 | ||
|
|
4952cbaf19 | ||
|
|
099f0b1ca2 | ||
|
|
eecbc0fc13 | ||
|
|
2d81a071e5 | ||
|
|
3e35181583 | ||
|
|
c3a3f41a2c | ||
|
|
2b2b471cfb | ||
|
|
abb319068b |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -30,6 +30,7 @@ Thumbs.db
|
|||||||
|
|
||||||
# Build artifacts
|
# Build artifacts
|
||||||
/dist/
|
/dist/
|
||||||
|
/build-staging
|
||||||
|
|
||||||
# Frontend (built files)
|
# Frontend (built files)
|
||||||
/web/node_modules/
|
/web/node_modules/
|
||||||
@@ -41,3 +42,4 @@ CLAUDE.md
|
|||||||
secrets.toml
|
secrets.toml
|
||||||
.env
|
.env
|
||||||
/docs/
|
/docs/
|
||||||
|
web/package-lock.json
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "one-kvm"
|
name = "one-kvm"
|
||||||
version = "0.1.7"
|
version = "0.1.9"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
authors = ["SilentWind"]
|
authors = ["SilentWind"]
|
||||||
description = "A open and lightweight IP-KVM solution written in Rust"
|
description = "A open and lightweight IP-KVM solution written in Rust"
|
||||||
|
|||||||
235
README.en.md
Normal file
235
README.en.md
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
<div align="center">
|
||||||
|
<h1>One-KVM</h1>
|
||||||
|
<p><strong>An open, lightweight IP-KVM stack in Rust — remote management down to BIOS level</strong></p>
|
||||||
|
|
||||||
|
<p><a href="README.md">简体中文</a> · <a href="README.en.md">English</a></p>
|
||||||
|
|
||||||
|
[](https://github.com/mofeng-git/One-KVM/releases)
|
||||||
|
[](https://github.com/mofeng-git/One-KVM/stargazers)
|
||||||
|
[](https://github.com/mofeng-git/One-KVM/network/members)
|
||||||
|
[](https://github.com/mofeng-git/One-KVM/issues)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
**One-KVM (Rust)** is a lightweight IP-KVM solution written in Rust. It lets you manage servers and workstations over the network, including at BIOS level.
|
||||||
|
|
||||||
|
Goals: an open, lightweight, easy-to-use IP-KVM stack.
|
||||||
|
|
||||||
|
- **Open**: not tied to one hardware recipe; runs across many setups.
|
||||||
|
- **Lightweight**: shipped as a binary with minimal moving parts for deployment.
|
||||||
|
- **Easy to use**: no hand-edited config files required; settings are done in the web UI.
|
||||||
|
|
||||||
|
> **One-KVM (Python)** is no longer maintained. If you still need it, see <https://github.com/mofeng-git/One-KVM/tree/python>.
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Core
|
||||||
|
|
||||||
|
| Area | Capabilities |
|
||||||
|
|------|----------------|
|
||||||
|
| Video capture | HDMI USB / MIPI CSI / RK3588 HDMI IN; MJPEG and WebRTC (H.264 / H.265 / VP8 / VP9) |
|
||||||
|
| Video encoding | VAAPI / QSV / RKMPP / V4L2 M2M hardware paths, with software fallback |
|
||||||
|
| Keyboard & mouse | USB OTG HID or CH340 + CH9329 HID; absolute / relative mouse |
|
||||||
|
| Virtual media | USB mass storage; ISO/IMG mount and Ventoy-style virtual USB |
|
||||||
|
| ATX power | GPIO or USB relay; power and reset control |
|
||||||
|
| Audio | ALSA capture + Opus (HTTP / WebRTC) |
|
||||||
|
|
||||||
|
The web UI supports visual configuration and Chinese/English locales. Built-ins include a web terminal (ttyd), intranet tunnel (gostc), P2P (EasyTier), RustDesk protocol (optional cross-platform remote access), and RTSP streaming.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Release artifacts are on [GitHub Releases](https://github.com/mofeng-git/One-KVM/releases). Below are short paths for common setups. For **system requirements, hardware, Docker env vars, USB OTG**, and full troubleshooting, see the [One-KVM documentation](https://docs.one-kvm.cn/) (Chinese; use a translator if needed).
|
||||||
|
|
||||||
|
### Debian / Ubuntu (.deb)
|
||||||
|
|
||||||
|
Download a `one-kvm_*.deb` matching your CPU architecture from Releases, then from the directory containing the package:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install ./one-kvm_0.x.x_<arch>.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the version and architecture in the filename with your actual file name.
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
Images:
|
||||||
|
|
||||||
|
- **one-kvm** — main app + ttyd
|
||||||
|
- **one-kvm-full** — same plus optional extras (e.g. gostc, easytier-core)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --name one-kvm -itd \
|
||||||
|
--privileged=true --restart unless-stopped \
|
||||||
|
-v /dev:/dev -v /sys:/sys \
|
||||||
|
--net=host \
|
||||||
|
silentwind0/one-kvm-full
|
||||||
|
```
|
||||||
|
|
||||||
|
If pulls are slow, use the Aliyun mirror, e.g. `registry.cn-hangzhou.aliyuncs.com/silentwind/one-kvm-full` (and `registry.cn-hangzhou.aliyuncs.com/silentwind/one-kvm` for the slim image).
|
||||||
|
|
||||||
|
### fnOS NAS (Feiniu / 飞牛)
|
||||||
|
|
||||||
|
One-KVM is listed in the fnOS **app store**; search and install on your NAS.
|
||||||
|
|
||||||
|
### Web UI and first run
|
||||||
|
|
||||||
|
Open `http://<device-ip>:8080` in a browser (**8420** after fnOS install). The first visit runs initial setup.
|
||||||
|
|
||||||
|
## Reporting issues
|
||||||
|
|
||||||
|
If something breaks:
|
||||||
|
|
||||||
|
1. Open [GitHub Issues](https://github.com/mofeng-git/One-KVM/issues) or report in the project QQ group.
|
||||||
|
2. Include **useful** error messages and steps to reproduce.
|
||||||
|
3. Mention software version, hardware, and OS details.
|
||||||
|
|
||||||
|
## Sponsorship
|
||||||
|
|
||||||
|
One-KVM builds on many great open-source projects; a lot of time goes into testing and maintenance. If you find it useful, you can support development on **[Afdian (为爱发电)](https://afdian.com/a/silentwind)**.
|
||||||
|
|
||||||
|
### Thanks
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary><strong>Supporter list</strong></summary>
|
||||||
|
|
||||||
|
- 浩龙的电子嵌入式之路
|
||||||
|
|
||||||
|
- Tsuki
|
||||||
|
|
||||||
|
- H_xiaoming
|
||||||
|
|
||||||
|
- 0蓝蓝0
|
||||||
|
|
||||||
|
- fairybl
|
||||||
|
|
||||||
|
- Will
|
||||||
|
|
||||||
|
- 自.知
|
||||||
|
|
||||||
|
- 观棋不语٩ ི۶
|
||||||
|
|
||||||
|
- 爱发电用户_a57a4
|
||||||
|
|
||||||
|
- 爱发电用户_2c769
|
||||||
|
|
||||||
|
- 霜序
|
||||||
|
|
||||||
|
- 远方(闲鱼用户名:小远技术店铺)
|
||||||
|
|
||||||
|
- 爱发电用户_399fc
|
||||||
|
|
||||||
|
- 斐斐の
|
||||||
|
|
||||||
|
- 爱发电用户_09451
|
||||||
|
|
||||||
|
- 超高校级的錆鱼
|
||||||
|
|
||||||
|
- 爱发电用户_08cff
|
||||||
|
|
||||||
|
- guoke
|
||||||
|
|
||||||
|
- mgt
|
||||||
|
|
||||||
|
- 姜沢掵
|
||||||
|
|
||||||
|
- ui_beam
|
||||||
|
|
||||||
|
- 爱发电用户_c0dd7
|
||||||
|
|
||||||
|
- 爱发电用户_dnjK
|
||||||
|
|
||||||
|
- 忍者胖猪
|
||||||
|
|
||||||
|
- 永遠の願い
|
||||||
|
|
||||||
|
- 爱发电用户_GBrF
|
||||||
|
|
||||||
|
- 爱发电用户_fd65c
|
||||||
|
|
||||||
|
- 爱发电用户_vhNa
|
||||||
|
|
||||||
|
- 爱发电用户_Xu6S
|
||||||
|
|
||||||
|
- moss
|
||||||
|
|
||||||
|
- woshididi
|
||||||
|
|
||||||
|
- 爱发电用户_a0fd1
|
||||||
|
|
||||||
|
- 爱发电用户_f6bH
|
||||||
|
|
||||||
|
- 码农
|
||||||
|
|
||||||
|
- 爱发电用户_6639f
|
||||||
|
|
||||||
|
- jeron
|
||||||
|
|
||||||
|
- 爱发电用户_CN7y
|
||||||
|
|
||||||
|
- 爱发电用户_Up6w
|
||||||
|
|
||||||
|
- 爱发电用户_e3202
|
||||||
|
|
||||||
|
- 一语念白
|
||||||
|
|
||||||
|
- 云边
|
||||||
|
|
||||||
|
- 爱发电用户_5a711
|
||||||
|
|
||||||
|
- 爱发电用户_9a706
|
||||||
|
|
||||||
|
- T0m9ir1SUKI
|
||||||
|
|
||||||
|
- 爱发电用户_56d52
|
||||||
|
|
||||||
|
- 爱发电用户_3N6F
|
||||||
|
|
||||||
|
- DUSK
|
||||||
|
|
||||||
|
- 飘零
|
||||||
|
|
||||||
|
- .
|
||||||
|
|
||||||
|
- 饭太稀
|
||||||
|
|
||||||
|
- 葱
|
||||||
|
|
||||||
|
- MaxZ
|
||||||
|
|
||||||
|
- 爱发电用户_c5f33
|
||||||
|
|
||||||
|
- 爱发电用户_09386
|
||||||
|
|
||||||
|
- 爱发电用户_JT6c
|
||||||
|
|
||||||
|
- 爱发电用户_d3d9c
|
||||||
|
|
||||||
|
- ......
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### Sponsors
|
||||||
|
|
||||||
|
**File hosting**
|
||||||
|
|
||||||
|
- **[Huang1111 public-interest program](https://pan.huang1111.cn/s/mxkx3T1)** — login-free downloads
|
||||||
|
|
||||||
|
**Cloud**
|
||||||
|
|
||||||
|
- **[林枫云](https://www.dkdun.cn)** — project server sponsorship
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
林枫云 offers premium network routes, high-frequency game servers, and high-bandwidth servers in China and abroad.
|
||||||
86
README.md
86
README.md
@@ -2,8 +2,9 @@
|
|||||||
<h1>One-KVM</h1>
|
<h1>One-KVM</h1>
|
||||||
<p><strong>Rust 编写的开放轻量 IP-KVM 解决方案,实现 BIOS 级远程管理</strong></p>
|
<p><strong>Rust 编写的开放轻量 IP-KVM 解决方案,实现 BIOS 级远程管理</strong></p>
|
||||||
|
|
||||||
<p><a href="README.md">简体中文</a></p>
|
<p><a href="README.md">简体中文</a> · <a href="README.en.md">English</a></p>
|
||||||
|
|
||||||
|
[](https://github.com/mofeng-git/One-KVM/releases)
|
||||||
[](https://github.com/mofeng-git/One-KVM/stargazers)
|
[](https://github.com/mofeng-git/One-KVM/stargazers)
|
||||||
[](https://github.com/mofeng-git/One-KVM/network/members)
|
[](https://github.com/mofeng-git/One-KVM/network/members)
|
||||||
[](https://github.com/mofeng-git/One-KVM/issues)
|
[](https://github.com/mofeng-git/One-KVM/issues)
|
||||||
@@ -15,57 +16,78 @@
|
|||||||
|
|
||||||
**One-KVM Rust** 是一个用 Rust 编写的轻量级 IP-KVM 解决方案,可通过网络远程管理服务器和工作站,实现 BIOS 级远程控制。
|
**One-KVM Rust** 是一个用 Rust 编写的轻量级 IP-KVM 解决方案,可通过网络远程管理服务器和工作站,实现 BIOS 级远程控制。
|
||||||
|
|
||||||
项目目标:
|
项目目标:提供一个开放、轻量、易用的 IPKVM 解决方案。
|
||||||
|
|
||||||
- **开放**:不绑定特定硬件配置,尽量适配常见 Linux 设备
|
- **开放**:不绑定特定硬件配置,可在各类硬件环境中稳定运行。
|
||||||
- **轻量**:单二进制分发,部署过程更简单
|
- **轻量**:以二进制文件形式分发,无繁杂的依赖项,部署过程简单。
|
||||||
- **易用**:网页界面完成设备与参数配置,无需手动改配置文件
|
- **易用**:无需手动编辑配置文件,参数设置均可通过网页界面完成。
|
||||||
|
|
||||||
> **注意:** One-KVM Rust 目前仍处于开发早期阶段,功能与细节会快速迭代,欢迎体验与反馈。
|
> **One-KVM Python** 已停止开发,如有需要可访问 <https://github.com/mofeng-git/One-KVM/tree/python>。
|
||||||
|
|
||||||
## 🔁 迁移说明
|
<div align="center">
|
||||||
|
|
||||||
开发重心正在从 **One-KVM Python** 逐步转向 **One-KVM Rust**。
|

|
||||||
|
|
||||||
- 如果你在使用 **One-KVM Python(基于 PiKVM)**,请查看 [One-KVM Python 文档](https://docs.one-kvm.cn/python/)
|
</div>
|
||||||
- One-KVM Rust 相较于 One-KVM Python:**尚未完全适配 CSI HDMI 采集卡**、**不支持 VNC 访问**,仍处于开发早期阶段
|
|
||||||
|
|
||||||
## 📊 功能介绍
|
## 📊 功能介绍
|
||||||
|
|
||||||
### 核心功能
|
### 核心功能
|
||||||
|
|
||||||
| 功能 | 说明 |
|
| 功能 | 能力说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 视频采集 | HDMI USB 采集卡支持,提供 MJPEG / WebRTC(H.264/H.265/VP8/VP9) |
|
| 视频采集 | HDMI USB /MIPI CSI/RK3588 HDMI IN 采集支持,提供 MJPEG / WebRTC(H.264/H.265/VP8/VP9) 视频流|
|
||||||
|
| 视频编码 | VAAPI/QSV/RKMPP/V4L2M2M 硬件编码支持,以及软件编码兜底 |
|
||||||
| 键鼠控制 | USB OTG HID 或 CH340 + CH9329 HID,支持绝对/相对鼠标模式 |
|
| 键鼠控制 | USB OTG HID 或 CH340 + CH9329 HID,支持绝对/相对鼠标模式 |
|
||||||
| 虚拟媒体 | USB Mass Storage,支持 ISO/IMG 镜像挂载和 Ventoy 虚拟U盘模式 |
|
| 虚拟媒体 | USB Mass Storage,支持 ISO/IMG 镜像挂载和 Ventoy 虚拟U盘模式 |
|
||||||
| ATX 电源控制 | GPIO 控制电源/重启按钮 |
|
| ATX 电源控制 | GPIO /USB 继电器,支持控制电源、重启按钮 |
|
||||||
| 音频传输 | ALSA 采集 + Opus 编码(HTTP/WebRTC) |
|
| 音频传输 | ALSA 采集 + Opus 编码(HTTP/WebRTC) |
|
||||||
|
|
||||||
### 硬件编码
|
此外提供基于 Web UI 的可视化配置与中英文界面;并集成 Web 终端(ttyd)、内网穿透(gostc)、P2P 组网(EasyTier)、RustDesk 协议(扩展跨平台远程访问)以及 RTSP 推流等能力。
|
||||||
|
|
||||||
支持自动检测和选择硬件加速:
|
|
||||||
|
|
||||||
- **VAAPI**:Intel/AMD GPU
|
|
||||||
- **RKMPP**:Rockchip SoC
|
|
||||||
- **V4L2 M2M**:通用硬件编码器
|
|
||||||
- **软件编码**:CPU 编码
|
|
||||||
|
|
||||||
### 扩展能力
|
|
||||||
|
|
||||||
- Web UI 配置,多语言支持(中文/英文)
|
|
||||||
- 内置 Web 终端(ttyd)、内网穿透支持(gostc)、P2P 组网支持(EasyTier)、RustDesk 协议集成(用于跨平台远程访问能力扩展)和 RTSP 视频流(用于视频推流)
|
|
||||||
|
|
||||||
## ⚡ 安装使用
|
## ⚡ 安装使用
|
||||||
|
|
||||||
可以访问 [One-KVM Rust 文档站点](https://docs.one-kvm.cn/) 获取详细信息。
|
构建产物见 [GitHub Releases](https://github.com/mofeng-git/One-KVM/releases)。以下为常见安装方式的简要步骤;**系统要求、硬件准备、Docker 环境变量与 USB OTG 等完整说明**请查阅 [One-KVM Rust 文档站点](https://docs.one-kvm.cn/)。
|
||||||
|
|
||||||
|
### 使用 deb 安装(Debian / Ubuntu)
|
||||||
|
|
||||||
|
从 Releases 下载与本机架构匹配的 `one-kvm_*.deb`,在包所在目录执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install ./one-kvm_0.x.x_<arch>.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
将文件名中的版本号与架构替换为实际下载的包名。
|
||||||
|
|
||||||
|
### 使用 Docker
|
||||||
|
|
||||||
|
镜像分为 **one-kvm**(One-KVM 主程序 + ttyd)与 **one-kvm-full**(另含 gostc、easytier-core 等可选扩展),按需选用。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --name one-kvm -itd \
|
||||||
|
--privileged=true --restart unless-stopped \
|
||||||
|
-v /dev:/dev -v /sys:/sys \
|
||||||
|
--net=host \
|
||||||
|
silentwind0/one-kvm-full
|
||||||
|
```
|
||||||
|
|
||||||
|
拉取较慢时,可将镜像名替换为阿里云加速,例如 `registry.cn-hangzhou.aliyuncs.com/silentwind/one-kvm-full`(`one-kvm` 镜像同理,将 `silentwind0/one-kvm` 换为 `registry.cn-hangzhou.aliyuncs.com/silentwind/one-kvm`)。
|
||||||
|
|
||||||
|
### 飞牛 NAS
|
||||||
|
|
||||||
|
One-KVM 已上架飞牛 **应用市场**,在 NAS 上直接搜索安装即可。
|
||||||
|
|
||||||
|
### 访问 Web 与首次配置
|
||||||
|
|
||||||
|
浏览器访问 `http://<设备 IP>:8080`(飞牛 NAS 安装后为 8420 端口)。首次访问将引导完成初始配置。
|
||||||
|
|
||||||
## 报告问题
|
## 报告问题
|
||||||
|
|
||||||
如果您发现了问题,请:
|
如果您发现了问题,请:
|
||||||
1. 使用 [GitHub Issues](https://github.com/mofeng-git/One-KVM/issues) 报告,或加入 QQ 群聊反馈。
|
1. 使用 [GitHub Issues](https://github.com/mofeng-git/One-KVM/issues) 报告,或加入 QQ 群聊反馈。
|
||||||
2. 提供详细的错误信息和复现步骤
|
2. 提供有帮助的错误信息和复现步骤
|
||||||
3. 包含您的硬件配置和系统信息
|
3. 包含您使用的软件版本、硬件配置和系统信息
|
||||||
|
|
||||||
## 赞助支持
|
## 赞助支持
|
||||||
|
|
||||||
@@ -88,8 +110,6 @@
|
|||||||
|
|
||||||
- Will
|
- Will
|
||||||
|
|
||||||
- 浩龙的电子嵌入式之路
|
|
||||||
|
|
||||||
- 自.知
|
- 自.知
|
||||||
|
|
||||||
- 观棋不语٩ ི۶
|
- 观棋不语٩ ི۶
|
||||||
@@ -188,8 +208,6 @@
|
|||||||
|
|
||||||
- 爱发电用户_JT6c
|
- 爱发电用户_JT6c
|
||||||
|
|
||||||
- MaxZ
|
|
||||||
|
|
||||||
- 爱发电用户_d3d9c
|
- 爱发电用户_d3d9c
|
||||||
|
|
||||||
- ......
|
- ......
|
||||||
@@ -205,7 +223,7 @@
|
|||||||
|
|
||||||
**云服务商**
|
**云服务商**
|
||||||
|
|
||||||
- **[林枫云](https://www.dkdun.cn)** - 赞助了本项目宁波大带宽服务器
|
- **[林枫云](https://www.dkdun.cn)** - 赞助了本项目服务器
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ enum AVPixelFormat {
|
|||||||
AV_PIX_FMT_NV24 = 188,
|
AV_PIX_FMT_NV24 = 188,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
int av_get_pix_fmt(const char *name);
|
||||||
int av_log_get_level(void);
|
int av_log_get_level(void);
|
||||||
void av_log_set_level(int level);
|
void av_log_set_level(int level);
|
||||||
void hwcodec_set_av_log_callback();
|
void hwcodec_set_av_log_callback();
|
||||||
|
|||||||
@@ -30,9 +30,15 @@ static int calculate_offset_length(int pix_fmt, int height, const int *linesize,
|
|||||||
*length = offset[1] + linesize[2] * height / 2;
|
*length = offset[1] + linesize[2] * height / 2;
|
||||||
break;
|
break;
|
||||||
case AV_PIX_FMT_NV12:
|
case AV_PIX_FMT_NV12:
|
||||||
|
case AV_PIX_FMT_NV21:
|
||||||
offset[0] = linesize[0] * height;
|
offset[0] = linesize[0] * height;
|
||||||
*length = offset[0] + linesize[1] * height / 2;
|
*length = offset[0] + linesize[1] * height / 2;
|
||||||
break;
|
break;
|
||||||
|
case AV_PIX_FMT_NV16:
|
||||||
|
case AV_PIX_FMT_NV24:
|
||||||
|
offset[0] = linesize[0] * height;
|
||||||
|
*length = offset[0] + linesize[1] * height;
|
||||||
|
break;
|
||||||
case AV_PIX_FMT_YUYV422:
|
case AV_PIX_FMT_YUYV422:
|
||||||
case AV_PIX_FMT_YVYU422:
|
case AV_PIX_FMT_YVYU422:
|
||||||
case AV_PIX_FMT_UYVY422:
|
case AV_PIX_FMT_UYVY422:
|
||||||
@@ -41,6 +47,11 @@ static int calculate_offset_length(int pix_fmt, int height, const int *linesize,
|
|||||||
offset[0] = 0; // Only one plane
|
offset[0] = 0; // Only one plane
|
||||||
*length = linesize[0] * height;
|
*length = linesize[0] * height;
|
||||||
break;
|
break;
|
||||||
|
case AV_PIX_FMT_RGB24:
|
||||||
|
case AV_PIX_FMT_BGR24:
|
||||||
|
offset[0] = 0; // Only one plane
|
||||||
|
*length = linesize[0] * height;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
LOG_ERROR(std::string("unsupported pixfmt") + std::to_string(pix_fmt));
|
LOG_ERROR(std::string("unsupported pixfmt") + std::to_string(pix_fmt));
|
||||||
return -1;
|
return -1;
|
||||||
@@ -397,9 +408,23 @@ private:
|
|||||||
const int *const offset) {
|
const int *const offset) {
|
||||||
switch (frame->format) {
|
switch (frame->format) {
|
||||||
case AV_PIX_FMT_NV12:
|
case AV_PIX_FMT_NV12:
|
||||||
|
case AV_PIX_FMT_NV21:
|
||||||
if (data_length <
|
if (data_length <
|
||||||
frame->height * (frame->linesize[0] + frame->linesize[1] / 2)) {
|
frame->height * (frame->linesize[0] + frame->linesize[1] / 2)) {
|
||||||
LOG_ERROR(std::string("fill_frame: NV12 data length error. data_length:") +
|
LOG_ERROR(std::string("fill_frame: NV12/NV21 data length error. data_length:") +
|
||||||
|
std::to_string(data_length) +
|
||||||
|
", linesize[0]:" + std::to_string(frame->linesize[0]) +
|
||||||
|
", linesize[1]:" + std::to_string(frame->linesize[1]));
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
frame->data[0] = data;
|
||||||
|
frame->data[1] = data + offset[0];
|
||||||
|
break;
|
||||||
|
case AV_PIX_FMT_NV16:
|
||||||
|
case AV_PIX_FMT_NV24:
|
||||||
|
if (data_length <
|
||||||
|
frame->height * (frame->linesize[0] + frame->linesize[1])) {
|
||||||
|
LOG_ERROR(std::string("fill_frame: NV16/NV24 data length error. data_length:") +
|
||||||
std::to_string(data_length) +
|
std::to_string(data_length) +
|
||||||
", linesize[0]:" + std::to_string(frame->linesize[0]) +
|
", linesize[0]:" + std::to_string(frame->linesize[0]) +
|
||||||
", linesize[1]:" + std::to_string(frame->linesize[1]));
|
", linesize[1]:" + std::to_string(frame->linesize[1]));
|
||||||
@@ -436,6 +461,17 @@ private:
|
|||||||
}
|
}
|
||||||
frame->data[0] = data;
|
frame->data[0] = data;
|
||||||
break;
|
break;
|
||||||
|
case AV_PIX_FMT_RGB24:
|
||||||
|
case AV_PIX_FMT_BGR24:
|
||||||
|
if (data_length < frame->height * frame->linesize[0]) {
|
||||||
|
LOG_ERROR(std::string("fill_frame: RGB24/BGR24 data length error. data_length:") +
|
||||||
|
std::to_string(data_length) +
|
||||||
|
", linesize[0]:" + std::to_string(frame->linesize[0]) +
|
||||||
|
", height:" + std::to_string(frame->height));
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
frame->data[0] = data;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
LOG_ERROR(std::string("fill_frame: unsupported format, ") +
|
LOG_ERROR(std::string("fill_frame: unsupported format, ") +
|
||||||
std::to_string(frame->format));
|
std::to_string(frame->format));
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
include!(concat!(env!("OUT_DIR"), "/ffmpeg_ffi.rs"));
|
include!(concat!(env!("OUT_DIR"), "/ffmpeg_ffi.rs"));
|
||||||
|
|
||||||
use serde_derive::{Deserialize, Serialize};
|
use serde_derive::{Deserialize, Serialize};
|
||||||
use std::env;
|
use std::{env, ffi::CString};
|
||||||
|
|
||||||
#[derive(Debug, Eq, PartialEq, Clone, Copy, Serialize, Deserialize)]
|
#[derive(Debug, Eq, PartialEq, Clone, Copy, Serialize, Deserialize)]
|
||||||
pub enum AVHWDeviceType {
|
pub enum AVHWDeviceType {
|
||||||
@@ -59,6 +59,22 @@ pub(crate) fn init_av_log() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn resolve_pixel_format(name: &str, fallback: AVPixelFormat) -> i32 {
|
||||||
|
let c_name = match CString::new(name) {
|
||||||
|
Ok(name) => name,
|
||||||
|
Err(_) => return fallback as i32,
|
||||||
|
};
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
let resolved = av_get_pix_fmt(c_name.as_ptr());
|
||||||
|
if resolved >= 0 {
|
||||||
|
resolved
|
||||||
|
} else {
|
||||||
|
fallback as i32
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_ffmpeg_log_level() -> i32 {
|
fn parse_ffmpeg_log_level() -> i32 {
|
||||||
let raw = match env::var("ONE_KVM_FFMPEG_LOG") {
|
let raw = match env::var("ONE_KVM_FFMPEG_LOG") {
|
||||||
Ok(value) => value,
|
Ok(value) => value,
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ fn enumerate_candidate_codecs(ctx: &EncodeContext) -> Vec<CodecInfo> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
codecs.retain(|codec| {
|
codecs.retain(|codec| {
|
||||||
!(ctx.pixfmt == AVPixelFormat::AV_PIX_FMT_YUV420P && codec.name.contains("qsv"))
|
!(ctx.pixfmt == AVPixelFormat::AV_PIX_FMT_YUV420P as i32 && codec.name.contains("qsv"))
|
||||||
});
|
});
|
||||||
codecs
|
codecs
|
||||||
}
|
}
|
||||||
@@ -428,7 +428,7 @@ pub struct EncodeContext {
|
|||||||
pub mc_name: Option<String>,
|
pub mc_name: Option<String>,
|
||||||
pub width: i32,
|
pub width: i32,
|
||||||
pub height: i32,
|
pub height: i32,
|
||||||
pub pixfmt: AVPixelFormat,
|
pub pixfmt: i32,
|
||||||
pub align: i32,
|
pub align: i32,
|
||||||
pub fps: i32,
|
pub fps: i32,
|
||||||
pub gop: i32,
|
pub gop: i32,
|
||||||
@@ -483,7 +483,7 @@ impl Encoder {
|
|||||||
CString::new(mc_name.as_str()).map_err(|_| ())?.as_ptr(),
|
CString::new(mc_name.as_str()).map_err(|_| ())?.as_ptr(),
|
||||||
ctx.width,
|
ctx.width,
|
||||||
ctx.height,
|
ctx.height,
|
||||||
ctx.pixfmt as c_int,
|
ctx.pixfmt,
|
||||||
ctx.align,
|
ctx.align,
|
||||||
ctx.fps,
|
ctx.fps,
|
||||||
ctx.gop,
|
ctx.gop,
|
||||||
|
|||||||
@@ -3,10 +3,7 @@
|
|||||||
#![allow(non_snake_case)]
|
#![allow(non_snake_case)]
|
||||||
|
|
||||||
use crate::common::DataFormat::{self, *};
|
use crate::common::DataFormat::{self, *};
|
||||||
use crate::ffmpeg::{
|
use crate::ffmpeg::AVHWDeviceType::{self, *};
|
||||||
AVHWDeviceType::{self, *},
|
|
||||||
AVPixelFormat,
|
|
||||||
};
|
|
||||||
use serde_derive::{Deserialize, Serialize};
|
use serde_derive::{Deserialize, Serialize};
|
||||||
use std::ffi::c_int;
|
use std::ffi::c_int;
|
||||||
|
|
||||||
@@ -234,7 +231,7 @@ impl CodecInfos {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn ffmpeg_linesize_offset_length(
|
pub fn ffmpeg_linesize_offset_length(
|
||||||
pixfmt: AVPixelFormat,
|
pixfmt: i32,
|
||||||
width: usize,
|
width: usize,
|
||||||
height: usize,
|
height: usize,
|
||||||
align: usize,
|
align: usize,
|
||||||
@@ -247,7 +244,7 @@ pub fn ffmpeg_linesize_offset_length(
|
|||||||
length.resize(1, 0);
|
length.resize(1, 0);
|
||||||
unsafe {
|
unsafe {
|
||||||
if ffmpeg_ram_get_linesize_offset_length(
|
if ffmpeg_ram_get_linesize_offset_length(
|
||||||
pixfmt as _,
|
pixfmt,
|
||||||
width as _,
|
width as _,
|
||||||
height as _,
|
height as _,
|
||||||
align as _,
|
align as _,
|
||||||
|
|||||||
@@ -34,10 +34,12 @@ fn generate_bindings(cpp_dir: &Path) {
|
|||||||
.allowlist_function("I420Copy")
|
.allowlist_function("I420Copy")
|
||||||
// I422 conversions
|
// I422 conversions
|
||||||
.allowlist_function("I422ToI420")
|
.allowlist_function("I422ToI420")
|
||||||
|
.allowlist_function("I444ToI420")
|
||||||
// NV12/NV21 conversions
|
// NV12/NV21 conversions
|
||||||
.allowlist_function("NV12ToI420")
|
.allowlist_function("NV12ToI420")
|
||||||
.allowlist_function("NV21ToI420")
|
.allowlist_function("NV21ToI420")
|
||||||
.allowlist_function("NV12Copy")
|
.allowlist_function("NV12Copy")
|
||||||
|
.allowlist_function("SplitUVPlane")
|
||||||
// ARGB/BGRA conversions
|
// ARGB/BGRA conversions
|
||||||
.allowlist_function("ARGBToI420")
|
.allowlist_function("ARGBToI420")
|
||||||
.allowlist_function("ARGBToNV12")
|
.allowlist_function("ARGBToNV12")
|
||||||
@@ -53,6 +55,7 @@ fn generate_bindings(cpp_dir: &Path) {
|
|||||||
// YUV to RGB conversions
|
// YUV to RGB conversions
|
||||||
.allowlist_function("I420ToRGB24")
|
.allowlist_function("I420ToRGB24")
|
||||||
.allowlist_function("I420ToARGB")
|
.allowlist_function("I420ToARGB")
|
||||||
|
.allowlist_function("H444ToARGB")
|
||||||
.allowlist_function("NV12ToRGB24")
|
.allowlist_function("NV12ToRGB24")
|
||||||
.allowlist_function("NV12ToARGB")
|
.allowlist_function("NV12ToARGB")
|
||||||
.allowlist_function("YUY2ToARGB")
|
.allowlist_function("YUY2ToARGB")
|
||||||
|
|||||||
@@ -58,6 +58,15 @@ int I422ToI420(const uint8_t* src_y, int src_stride_y,
|
|||||||
uint8_t* dst_v, int dst_stride_v,
|
uint8_t* dst_v, int dst_stride_v,
|
||||||
int width, int height);
|
int width, int height);
|
||||||
|
|
||||||
|
// I444 (YUV444P) -> I420 (YUV420P) with horizontal and vertical chroma downsampling
|
||||||
|
int I444ToI420(const uint8_t* src_y, int src_stride_y,
|
||||||
|
const uint8_t* src_u, int src_stride_u,
|
||||||
|
const uint8_t* src_v, int src_stride_v,
|
||||||
|
uint8_t* dst_y, int dst_stride_y,
|
||||||
|
uint8_t* dst_u, int dst_stride_u,
|
||||||
|
uint8_t* dst_v, int dst_stride_v,
|
||||||
|
int width, int height);
|
||||||
|
|
||||||
// I420 -> NV12
|
// I420 -> NV12
|
||||||
int I420ToNV12(const uint8_t* src_y, int src_stride_y,
|
int I420ToNV12(const uint8_t* src_y, int src_stride_y,
|
||||||
const uint8_t* src_u, int src_stride_u,
|
const uint8_t* src_u, int src_stride_u,
|
||||||
@@ -94,6 +103,12 @@ int NV21ToI420(const uint8_t* src_y, int src_stride_y,
|
|||||||
uint8_t* dst_v, int dst_stride_v,
|
uint8_t* dst_v, int dst_stride_v,
|
||||||
int width, int height);
|
int width, int height);
|
||||||
|
|
||||||
|
// Split interleaved UV plane into separate U and V planes
|
||||||
|
void SplitUVPlane(const uint8_t* src_uv, int src_stride_uv,
|
||||||
|
uint8_t* dst_u, int dst_stride_u,
|
||||||
|
uint8_t* dst_v, int dst_stride_v,
|
||||||
|
int width, int height);
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
// ARGB/BGRA conversions (32-bit RGB)
|
// ARGB/BGRA conversions (32-bit RGB)
|
||||||
// Note: libyuv uses ARGB to mean BGRA in memory (little-endian)
|
// Note: libyuv uses ARGB to mean BGRA in memory (little-endian)
|
||||||
@@ -180,6 +195,13 @@ int I420ToARGB(const uint8_t* src_y, int src_stride_y,
|
|||||||
uint8_t* dst_argb, int dst_stride_argb,
|
uint8_t* dst_argb, int dst_stride_argb,
|
||||||
int width, int height);
|
int width, int height);
|
||||||
|
|
||||||
|
// H444 (BT.709 limited-range YUV444P) -> ARGB (BGRA)
|
||||||
|
int H444ToARGB(const uint8_t* src_y, int src_stride_y,
|
||||||
|
const uint8_t* src_u, int src_stride_u,
|
||||||
|
const uint8_t* src_v, int src_stride_v,
|
||||||
|
uint8_t* dst_argb, int dst_stride_argb,
|
||||||
|
int width, int height);
|
||||||
|
|
||||||
// NV12 -> RGB24
|
// NV12 -> RGB24
|
||||||
int NV12ToRGB24(const uint8_t* src_y, int src_stride_y,
|
int NV12ToRGB24(const uint8_t* src_y, int src_stride_y,
|
||||||
const uint8_t* src_uv, int src_stride_uv,
|
const uint8_t* src_uv, int src_stride_uv,
|
||||||
|
|||||||
@@ -297,6 +297,93 @@ pub fn i422_to_i420_planar(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert I444 (YUV444P) to I420 (YUV420P) with separate planes and explicit strides
|
||||||
|
/// This performs horizontal and vertical chroma downsampling using SIMD
|
||||||
|
pub fn i444_to_i420_planar(
|
||||||
|
src_y: &[u8],
|
||||||
|
src_y_stride: i32,
|
||||||
|
src_u: &[u8],
|
||||||
|
src_u_stride: i32,
|
||||||
|
src_v: &[u8],
|
||||||
|
src_v_stride: i32,
|
||||||
|
dst: &mut [u8],
|
||||||
|
width: i32,
|
||||||
|
height: i32,
|
||||||
|
) -> Result<()> {
|
||||||
|
if width % 2 != 0 || height % 2 != 0 {
|
||||||
|
return Err(YuvError::InvalidDimensions);
|
||||||
|
}
|
||||||
|
|
||||||
|
let w = width as usize;
|
||||||
|
let h = height as usize;
|
||||||
|
let y_size = w * h;
|
||||||
|
let uv_size = (w / 2) * (h / 2);
|
||||||
|
|
||||||
|
if dst.len() < i420_size(w, h) {
|
||||||
|
return Err(YuvError::BufferTooSmall);
|
||||||
|
}
|
||||||
|
|
||||||
|
call_yuv!(I444ToI420(
|
||||||
|
src_y.as_ptr(),
|
||||||
|
src_y_stride,
|
||||||
|
src_u.as_ptr(),
|
||||||
|
src_u_stride,
|
||||||
|
src_v.as_ptr(),
|
||||||
|
src_v_stride,
|
||||||
|
dst.as_mut_ptr(),
|
||||||
|
width,
|
||||||
|
dst[y_size..].as_mut_ptr(),
|
||||||
|
width / 2,
|
||||||
|
dst[y_size + uv_size..].as_mut_ptr(),
|
||||||
|
width / 2,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split an interleaved UV plane into separate U and V planes using libyuv SIMD helpers.
|
||||||
|
///
|
||||||
|
/// `width` is the number of chroma samples per row, not the number of source bytes.
|
||||||
|
pub fn split_uv_plane(
|
||||||
|
src_uv: &[u8],
|
||||||
|
src_stride_uv: i32,
|
||||||
|
dst_u: &mut [u8],
|
||||||
|
dst_stride_u: i32,
|
||||||
|
dst_v: &mut [u8],
|
||||||
|
dst_stride_v: i32,
|
||||||
|
width: i32,
|
||||||
|
height: i32,
|
||||||
|
) -> Result<()> {
|
||||||
|
if width <= 0 || height <= 0 {
|
||||||
|
return Err(YuvError::InvalidDimensions);
|
||||||
|
}
|
||||||
|
|
||||||
|
let width = width as usize;
|
||||||
|
let height = height as usize;
|
||||||
|
let src_required = (src_stride_uv as usize).saturating_mul(height);
|
||||||
|
let dst_u_required = (dst_stride_u as usize).saturating_mul(height);
|
||||||
|
let dst_v_required = (dst_stride_v as usize).saturating_mul(height);
|
||||||
|
|
||||||
|
if src_uv.len() < src_required || dst_u.len() < dst_u_required || dst_v.len() < dst_v_required {
|
||||||
|
return Err(YuvError::BufferTooSmall);
|
||||||
|
}
|
||||||
|
|
||||||
|
unsafe {
|
||||||
|
SplitUVPlane(
|
||||||
|
src_uv.as_ptr(),
|
||||||
|
src_stride_uv,
|
||||||
|
dst_u.as_mut_ptr(),
|
||||||
|
dst_stride_u,
|
||||||
|
dst_v.as_mut_ptr(),
|
||||||
|
dst_stride_v,
|
||||||
|
width as i32,
|
||||||
|
height as i32,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// I420 <-> NV12 conversions
|
// I420 <-> NV12 conversions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -761,6 +848,41 @@ pub fn i420_to_bgra(src: &[u8], dst: &mut [u8], width: i32, height: i32) -> Resu
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert H444 (BT.709 limited-range YUV444P) to BGRA.
|
||||||
|
pub fn h444_to_bgra(
|
||||||
|
src_y: &[u8],
|
||||||
|
src_u: &[u8],
|
||||||
|
src_v: &[u8],
|
||||||
|
dst: &mut [u8],
|
||||||
|
width: i32,
|
||||||
|
height: i32,
|
||||||
|
) -> Result<()> {
|
||||||
|
let w = width as usize;
|
||||||
|
let h = height as usize;
|
||||||
|
let plane_size = w * h;
|
||||||
|
|
||||||
|
if src_y.len() < plane_size || src_u.len() < plane_size || src_v.len() < plane_size {
|
||||||
|
return Err(YuvError::BufferTooSmall);
|
||||||
|
}
|
||||||
|
|
||||||
|
if dst.len() < argb_size(w, h) {
|
||||||
|
return Err(YuvError::BufferTooSmall);
|
||||||
|
}
|
||||||
|
|
||||||
|
call_yuv!(H444ToARGB(
|
||||||
|
src_y.as_ptr(),
|
||||||
|
width,
|
||||||
|
src_u.as_ptr(),
|
||||||
|
width,
|
||||||
|
src_v.as_ptr(),
|
||||||
|
width,
|
||||||
|
dst.as_mut_ptr(),
|
||||||
|
width * 4,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert NV12 to RGB24
|
/// Convert NV12 to RGB24
|
||||||
pub fn nv12_to_rgb24(src: &[u8], dst: &mut [u8], width: i32, height: i32) -> Result<()> {
|
pub fn nv12_to_rgb24(src: &[u8], dst: &mut [u8], width: i32, height: i32) -> Result<()> {
|
||||||
if width % 2 != 0 || height % 2 != 0 {
|
if width % 2 != 0 || height % 2 != 0 {
|
||||||
|
|||||||
341
scripts/build-update-site.sh
Executable file
341
scripts/build-update-site.sh
Executable file
@@ -0,0 +1,341 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# 生成 One-KVM 在线升级静态站点并打包为可部署 tar.gz。
|
||||||
|
# 输出目录结构:
|
||||||
|
# <site_name>/v1/channels.json
|
||||||
|
# <site_name>/v1/releases.json
|
||||||
|
# <site_name>/v1/bin/<version>/one-kvm-<triple>
|
||||||
|
#
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||||
|
|
||||||
|
VERSION=""
|
||||||
|
RELEASE_CHANNEL="stable"
|
||||||
|
STABLE_VERSION=""
|
||||||
|
BETA_VERSION=""
|
||||||
|
PUBLISHED_AT="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||||
|
ARTIFACTS_DIR=""
|
||||||
|
X86_64_BIN=""
|
||||||
|
AARCH64_BIN=""
|
||||||
|
ARMV7_BIN=""
|
||||||
|
X86_64_SET=0
|
||||||
|
AARCH64_SET=0
|
||||||
|
ARMV7_SET=0
|
||||||
|
SITE_NAME="one-kvm-update"
|
||||||
|
OUTPUT_FILE=""
|
||||||
|
OUTPUT_DIR="${PROJECT_ROOT}/dist"
|
||||||
|
declare -a NOTES=()
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage:
|
||||||
|
./scripts/build-update-site.sh --version <x.x.x> [options]
|
||||||
|
|
||||||
|
Required:
|
||||||
|
--version <x.x.x> Release 版本号(如 0.1.10)
|
||||||
|
|
||||||
|
Artifact input (二选一,可混用):
|
||||||
|
--artifacts-dir <dir> 自动扫描目录中的标准文件名:
|
||||||
|
one-kvm-x86_64-unknown-linux-gnu
|
||||||
|
one-kvm-aarch64-unknown-linux-gnu
|
||||||
|
one-kvm-armv7-unknown-linux-gnueabihf
|
||||||
|
--x86_64 <file> 指定 x86_64 二进制路径
|
||||||
|
--aarch64 <file> 指定 aarch64 二进制路径
|
||||||
|
--armv7 <file> 指定 armv7 二进制路径
|
||||||
|
|
||||||
|
Manifest options:
|
||||||
|
--release-channel <stable|beta> releases.json 里该版本所属渠道,默认 stable
|
||||||
|
--stable <x.x.x> channels.json 的 stable 指针,默认等于 --version
|
||||||
|
--beta <x.x.x> channels.json 的 beta 指针,默认等于 --version
|
||||||
|
--published-at <RFC3339> 发布时间,默认当前 UTC 时间
|
||||||
|
--note <text> 发布说明,可重复传入多次
|
||||||
|
|
||||||
|
Output options:
|
||||||
|
--site-name <name> 打包根目录名,默认 one-kvm-update
|
||||||
|
--output-dir <dir> 输出目录(默认 <repo>/dist)
|
||||||
|
--output <file.tar.gz> 输出包完整路径(优先级高于 --output-dir)
|
||||||
|
|
||||||
|
Other:
|
||||||
|
-h, --help 显示帮助
|
||||||
|
|
||||||
|
Example:
|
||||||
|
./scripts/build-update-site.sh \
|
||||||
|
--version 0.1.10 \
|
||||||
|
--artifacts-dir ./target/release \
|
||||||
|
--release-channel stable \
|
||||||
|
--stable 0.1.10 \
|
||||||
|
--beta 0.1.11 \
|
||||||
|
--note "修复 WebRTC 断流问题" \
|
||||||
|
--note "优化 HID 输入延迟"
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "Error: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
require_cmd() {
|
||||||
|
local cmd="$1"
|
||||||
|
command -v "$cmd" >/dev/null 2>&1 || fail "Missing required command: ${cmd}"
|
||||||
|
}
|
||||||
|
|
||||||
|
json_escape() {
|
||||||
|
local s="$1"
|
||||||
|
s=${s//\\/\\\\}
|
||||||
|
s=${s//\"/\\\"}
|
||||||
|
s=${s//$'\n'/\\n}
|
||||||
|
s=${s//$'\r'/\\r}
|
||||||
|
s=${s//$'\t'/\\t}
|
||||||
|
printf '%s' "$s"
|
||||||
|
}
|
||||||
|
|
||||||
|
is_valid_version() {
|
||||||
|
local v="$1"
|
||||||
|
[[ "$v" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
is_valid_channel() {
|
||||||
|
local c="$1"
|
||||||
|
[[ "$c" == "stable" || "$c" == "beta" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--version)
|
||||||
|
VERSION="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--release-channel)
|
||||||
|
RELEASE_CHANNEL="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--stable)
|
||||||
|
STABLE_VERSION="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--beta)
|
||||||
|
BETA_VERSION="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--published-at)
|
||||||
|
PUBLISHED_AT="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--note)
|
||||||
|
NOTES+=("${2:-}")
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--artifacts-dir)
|
||||||
|
ARTIFACTS_DIR="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--x86_64)
|
||||||
|
X86_64_BIN="${2:-}"
|
||||||
|
X86_64_SET=1
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--aarch64)
|
||||||
|
AARCH64_BIN="${2:-}"
|
||||||
|
AARCH64_SET=1
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--armv7)
|
||||||
|
ARMV7_BIN="${2:-}"
|
||||||
|
ARMV7_SET=1
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--site-name)
|
||||||
|
SITE_NAME="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--output-dir)
|
||||||
|
OUTPUT_DIR="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--output)
|
||||||
|
OUTPUT_FILE="${2:-}"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-h | --help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
fail "Unknown argument: $1 (use --help)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
require_cmd sha256sum
|
||||||
|
require_cmd stat
|
||||||
|
require_cmd tar
|
||||||
|
require_cmd mktemp
|
||||||
|
|
||||||
|
[[ -n "$VERSION" ]] || fail "--version is required"
|
||||||
|
is_valid_version "$VERSION" || fail "Invalid --version: ${VERSION} (expected x.x.x)"
|
||||||
|
is_valid_channel "$RELEASE_CHANNEL" || fail "Invalid --release-channel: ${RELEASE_CHANNEL}"
|
||||||
|
|
||||||
|
if [[ -z "$STABLE_VERSION" ]]; then
|
||||||
|
STABLE_VERSION="$VERSION"
|
||||||
|
fi
|
||||||
|
if [[ -z "$BETA_VERSION" ]]; then
|
||||||
|
BETA_VERSION="$VERSION"
|
||||||
|
fi
|
||||||
|
is_valid_version "$STABLE_VERSION" || fail "Invalid --stable: ${STABLE_VERSION}"
|
||||||
|
is_valid_version "$BETA_VERSION" || fail "Invalid --beta: ${BETA_VERSION}"
|
||||||
|
|
||||||
|
if [[ -n "$ARTIFACTS_DIR" ]]; then
|
||||||
|
[[ -d "$ARTIFACTS_DIR" ]] || fail "--artifacts-dir not found: ${ARTIFACTS_DIR}"
|
||||||
|
[[ -n "$X86_64_BIN" ]] || X86_64_BIN="${ARTIFACTS_DIR}/one-kvm-x86_64-unknown-linux-gnu"
|
||||||
|
[[ -n "$AARCH64_BIN" ]] || AARCH64_BIN="${ARTIFACTS_DIR}/one-kvm-aarch64-unknown-linux-gnu"
|
||||||
|
[[ -n "$ARMV7_BIN" ]] || ARMV7_BIN="${ARTIFACTS_DIR}/one-kvm-armv7-unknown-linux-gnueabihf"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$X86_64_SET" -eq 1 && ! -f "$X86_64_BIN" ]]; then
|
||||||
|
fail "--x86_64 file not found: ${X86_64_BIN}"
|
||||||
|
fi
|
||||||
|
if [[ "$AARCH64_SET" -eq 1 && ! -f "$AARCH64_BIN" ]]; then
|
||||||
|
fail "--aarch64 file not found: ${AARCH64_BIN}"
|
||||||
|
fi
|
||||||
|
if [[ "$ARMV7_SET" -eq 1 && ! -f "$ARMV7_BIN" ]]; then
|
||||||
|
fail "--armv7 file not found: ${ARMV7_BIN}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
declare -A SRC_BY_TRIPLE=()
|
||||||
|
if [[ -n "$X86_64_BIN" && -f "$X86_64_BIN" ]]; then
|
||||||
|
SRC_BY_TRIPLE["x86_64-unknown-linux-gnu"]="$X86_64_BIN"
|
||||||
|
fi
|
||||||
|
if [[ -n "$AARCH64_BIN" && -f "$AARCH64_BIN" ]]; then
|
||||||
|
SRC_BY_TRIPLE["aarch64-unknown-linux-gnu"]="$AARCH64_BIN"
|
||||||
|
fi
|
||||||
|
if [[ -n "$ARMV7_BIN" && -f "$ARMV7_BIN" ]]; then
|
||||||
|
SRC_BY_TRIPLE["armv7-unknown-linux-gnueabihf"]="$ARMV7_BIN"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ${#SRC_BY_TRIPLE[@]} -eq 0 ]]; then
|
||||||
|
fail "No artifact found. Provide --artifacts-dir or at least one of --x86_64/--aarch64/--armv7."
|
||||||
|
fi
|
||||||
|
|
||||||
|
BUILD_DIR="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$BUILD_DIR"' EXIT
|
||||||
|
|
||||||
|
SITE_DIR="${BUILD_DIR}/${SITE_NAME}"
|
||||||
|
V1_DIR="${SITE_DIR}/v1"
|
||||||
|
BIN_DIR="${V1_DIR}/bin/${VERSION}"
|
||||||
|
mkdir -p "$BIN_DIR"
|
||||||
|
|
||||||
|
declare -A SHA_BY_TRIPLE=()
|
||||||
|
declare -A SIZE_BY_TRIPLE=()
|
||||||
|
|
||||||
|
TRIPLES=(
|
||||||
|
"x86_64-unknown-linux-gnu"
|
||||||
|
"aarch64-unknown-linux-gnu"
|
||||||
|
"armv7-unknown-linux-gnueabihf"
|
||||||
|
)
|
||||||
|
|
||||||
|
for triple in "${TRIPLES[@]}"; do
|
||||||
|
src="${SRC_BY_TRIPLE[$triple]:-}"
|
||||||
|
if [[ -z "$src" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
[[ -f "$src" ]] || fail "Artifact not found for ${triple}: ${src}"
|
||||||
|
|
||||||
|
dest_name="one-kvm-${triple}"
|
||||||
|
dest_path="${BIN_DIR}/${dest_name}"
|
||||||
|
cp "$src" "$dest_path"
|
||||||
|
|
||||||
|
sha="$(sha256sum "$dest_path" | awk '{print $1}')"
|
||||||
|
size="$(stat -c%s "$dest_path")"
|
||||||
|
SHA_BY_TRIPLE["$triple"]="$sha"
|
||||||
|
SIZE_BY_TRIPLE["$triple"]="$size"
|
||||||
|
done
|
||||||
|
|
||||||
|
cat >"${V1_DIR}/channels.json" <<EOF
|
||||||
|
{
|
||||||
|
"stable": "${STABLE_VERSION}",
|
||||||
|
"beta": "${BETA_VERSION}"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
RELEASES_FILE="${V1_DIR}/releases.json"
|
||||||
|
{
|
||||||
|
echo '{'
|
||||||
|
echo ' "releases": ['
|
||||||
|
echo ' {'
|
||||||
|
echo " \"version\": \"${VERSION}\","
|
||||||
|
echo " \"channel\": \"${RELEASE_CHANNEL}\","
|
||||||
|
echo " \"published_at\": \"${PUBLISHED_AT}\","
|
||||||
|
|
||||||
|
if [[ ${#NOTES[@]} -eq 0 ]]; then
|
||||||
|
echo ' "notes": [],'
|
||||||
|
else
|
||||||
|
echo ' "notes": ['
|
||||||
|
for i in "${!NOTES[@]}"; do
|
||||||
|
esc_note="$(json_escape "${NOTES[$i]}")"
|
||||||
|
if [[ "$i" -lt $((${#NOTES[@]} - 1)) ]]; then
|
||||||
|
echo " \"${esc_note}\","
|
||||||
|
else
|
||||||
|
echo " \"${esc_note}\""
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo ' ],'
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ' "artifacts": {'
|
||||||
|
written=0
|
||||||
|
for triple in "${TRIPLES[@]}"; do
|
||||||
|
if [[ -z "${SHA_BY_TRIPLE[$triple]:-}" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
url="/v1/bin/${VERSION}/one-kvm-${triple}"
|
||||||
|
if [[ $written -eq 1 ]]; then
|
||||||
|
echo ','
|
||||||
|
fi
|
||||||
|
cat <<EOF
|
||||||
|
"${triple}": {
|
||||||
|
"url": "${url}",
|
||||||
|
"sha256": "${SHA_BY_TRIPLE[$triple]}",
|
||||||
|
"size": ${SIZE_BY_TRIPLE[$triple]}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
written=1
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
echo ' }'
|
||||||
|
echo ' }'
|
||||||
|
echo ' ]'
|
||||||
|
echo '}'
|
||||||
|
} >"$RELEASES_FILE"
|
||||||
|
|
||||||
|
if [[ -n "$OUTPUT_FILE" ]]; then
|
||||||
|
if [[ "$OUTPUT_FILE" != /* ]]; then
|
||||||
|
OUTPUT_FILE="${PROJECT_ROOT}/${OUTPUT_FILE}"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
mkdir -p "$OUTPUT_DIR"
|
||||||
|
OUTPUT_FILE="${OUTPUT_DIR}/${SITE_NAME}-${VERSION}.tar.gz"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$OUTPUT_FILE")"
|
||||||
|
tar -C "$BUILD_DIR" -czf "$OUTPUT_FILE" "$SITE_NAME"
|
||||||
|
|
||||||
|
echo "Build complete:"
|
||||||
|
echo " package: ${OUTPUT_FILE}"
|
||||||
|
echo " site root in tar: ${SITE_NAME}/"
|
||||||
|
echo " release version: ${VERSION}"
|
||||||
|
echo " release channel: ${RELEASE_CHANNEL}"
|
||||||
|
echo " channels: stable=${STABLE_VERSION}, beta=${BETA_VERSION}"
|
||||||
|
echo " artifacts:"
|
||||||
|
for triple in "${TRIPLES[@]}"; do
|
||||||
|
if [[ -n "${SHA_BY_TRIPLE[$triple]:-}" ]]; then
|
||||||
|
echo " - ${triple}: size=${SIZE_BY_TRIPLE[$triple]} sha256=${SHA_BY_TRIPLE[$triple]}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo
|
||||||
|
echo "Deploy example:"
|
||||||
|
echo " tar -xzf \"${OUTPUT_FILE}\" -C /var/www/"
|
||||||
|
echo " # then ensure nginx root points to /var/www/${SITE_NAME}"
|
||||||
@@ -43,7 +43,63 @@ pub struct AtxController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AtxController {
|
impl AtxController {
|
||||||
|
fn should_share_serial_device(power: &AtxKeyConfig, reset: &AtxKeyConfig) -> bool {
|
||||||
|
power.is_configured()
|
||||||
|
&& reset.is_configured()
|
||||||
|
&& power.driver == super::types::AtxDriverType::Serial
|
||||||
|
&& reset.driver == super::types::AtxDriverType::Serial
|
||||||
|
&& !power.device.is_empty()
|
||||||
|
&& power.device == reset.device
|
||||||
|
&& power.baud_rate == reset.baud_rate
|
||||||
|
}
|
||||||
|
|
||||||
async fn init_components(inner: &mut AtxInner) {
|
async fn init_components(inner: &mut AtxInner) {
|
||||||
|
if Self::should_share_serial_device(&inner.config.power, &inner.config.reset) {
|
||||||
|
match AtxKeyExecutor::open_shared_serial(
|
||||||
|
&inner.config.power.device,
|
||||||
|
inner.config.power.baud_rate,
|
||||||
|
) {
|
||||||
|
Ok(shared_serial) => {
|
||||||
|
let mut power_executor = AtxKeyExecutor::new_with_shared_serial(
|
||||||
|
inner.config.power.clone(),
|
||||||
|
shared_serial.clone(),
|
||||||
|
);
|
||||||
|
if let Err(e) = power_executor.init().await {
|
||||||
|
warn!("Failed to initialize power executor: {}", e);
|
||||||
|
} else {
|
||||||
|
info!(
|
||||||
|
"Power executor initialized: {:?} on {} pin {}",
|
||||||
|
inner.config.power.driver,
|
||||||
|
inner.config.power.device,
|
||||||
|
inner.config.power.pin
|
||||||
|
);
|
||||||
|
inner.power_executor = Some(power_executor);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut reset_executor = AtxKeyExecutor::new_with_shared_serial(
|
||||||
|
inner.config.reset.clone(),
|
||||||
|
shared_serial,
|
||||||
|
);
|
||||||
|
if let Err(e) = reset_executor.init().await {
|
||||||
|
warn!("Failed to initialize reset executor: {}", e);
|
||||||
|
} else {
|
||||||
|
info!(
|
||||||
|
"Reset executor initialized: {:?} on {} pin {}",
|
||||||
|
inner.config.reset.driver,
|
||||||
|
inner.config.reset.device,
|
||||||
|
inner.config.reset.pin
|
||||||
|
);
|
||||||
|
inner.reset_executor = Some(reset_executor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
"Failed to open shared serial device {} for ATX power/reset: {}",
|
||||||
|
inner.config.power.device, e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
// Initialize power executor
|
// Initialize power executor
|
||||||
if inner.config.power.is_configured() {
|
if inner.config.power.is_configured() {
|
||||||
let mut executor = AtxKeyExecutor::new(inner.config.power.clone());
|
let mut executor = AtxKeyExecutor::new(inner.config.power.clone());
|
||||||
@@ -52,7 +108,9 @@ impl AtxController {
|
|||||||
} else {
|
} else {
|
||||||
info!(
|
info!(
|
||||||
"Power executor initialized: {:?} on {} pin {}",
|
"Power executor initialized: {:?} on {} pin {}",
|
||||||
inner.config.power.driver, inner.config.power.device, inner.config.power.pin
|
inner.config.power.driver,
|
||||||
|
inner.config.power.device,
|
||||||
|
inner.config.power.pin
|
||||||
);
|
);
|
||||||
inner.power_executor = Some(executor);
|
inner.power_executor = Some(executor);
|
||||||
}
|
}
|
||||||
@@ -66,11 +124,14 @@ impl AtxController {
|
|||||||
} else {
|
} else {
|
||||||
info!(
|
info!(
|
||||||
"Reset executor initialized: {:?} on {} pin {}",
|
"Reset executor initialized: {:?} on {} pin {}",
|
||||||
inner.config.reset.driver, inner.config.reset.device, inner.config.reset.pin
|
inner.config.reset.driver,
|
||||||
|
inner.config.reset.device,
|
||||||
|
inner.config.reset.pin
|
||||||
);
|
);
|
||||||
inner.reset_executor = Some(executor);
|
inner.reset_executor = Some(executor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize LED sensor
|
// Initialize LED sensor
|
||||||
if inner.config.led.is_configured() {
|
if inner.config.led.is_configured() {
|
||||||
@@ -262,3 +323,49 @@ impl AtxController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::atx::AtxDriverType;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_should_share_serial_device_true() {
|
||||||
|
let power = AtxKeyConfig {
|
||||||
|
driver: AtxDriverType::Serial,
|
||||||
|
device: "/dev/ttyUSB0".to_string(),
|
||||||
|
pin: 1,
|
||||||
|
active_level: super::super::types::ActiveLevel::High,
|
||||||
|
baud_rate: 9600,
|
||||||
|
};
|
||||||
|
let reset = AtxKeyConfig {
|
||||||
|
driver: AtxDriverType::Serial,
|
||||||
|
device: "/dev/ttyUSB0".to_string(),
|
||||||
|
pin: 2,
|
||||||
|
active_level: super::super::types::ActiveLevel::High,
|
||||||
|
baud_rate: 9600,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(AtxController::should_share_serial_device(&power, &reset));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_should_share_serial_device_false_on_different_baud() {
|
||||||
|
let power = AtxKeyConfig {
|
||||||
|
driver: AtxDriverType::Serial,
|
||||||
|
device: "/dev/ttyUSB0".to_string(),
|
||||||
|
pin: 1,
|
||||||
|
active_level: super::super::types::ActiveLevel::High,
|
||||||
|
baud_rate: 9600,
|
||||||
|
};
|
||||||
|
let reset = AtxKeyConfig {
|
||||||
|
driver: AtxDriverType::Serial,
|
||||||
|
device: "/dev/ttyUSB0".to_string(),
|
||||||
|
pin: 2,
|
||||||
|
active_level: super::super::types::ActiveLevel::High,
|
||||||
|
baud_rate: 115200,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(!AtxController::should_share_serial_device(&power, &reset));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use serialport::SerialPort;
|
|||||||
use std::fs::{File, OpenOptions};
|
use std::fs::{File, OpenOptions};
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Mutex;
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
use tracing::{debug, info};
|
use tracing::{debug, info};
|
||||||
@@ -16,6 +16,8 @@ use tracing::{debug, info};
|
|||||||
use super::types::{ActiveLevel, AtxDriverType, AtxKeyConfig};
|
use super::types::{ActiveLevel, AtxDriverType, AtxKeyConfig};
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
|
|
||||||
|
pub type SharedSerialHandle = Arc<Mutex<Box<dyn SerialPort>>>;
|
||||||
|
|
||||||
/// Timing constants for ATX operations
|
/// Timing constants for ATX operations
|
||||||
pub mod timing {
|
pub mod timing {
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
@@ -39,8 +41,8 @@ pub struct AtxKeyExecutor {
|
|||||||
gpio_handle: Mutex<Option<LineHandle>>,
|
gpio_handle: Mutex<Option<LineHandle>>,
|
||||||
/// Cached USB relay file handle to avoid repeated open/close syscalls
|
/// Cached USB relay file handle to avoid repeated open/close syscalls
|
||||||
usb_relay_handle: Mutex<Option<File>>,
|
usb_relay_handle: Mutex<Option<File>>,
|
||||||
/// Cached Serial port handle
|
/// Cached Serial port handle (can be shared across power/reset executors)
|
||||||
serial_handle: Mutex<Option<Box<dyn SerialPort>>>,
|
serial_handle: Mutex<Option<SharedSerialHandle>>,
|
||||||
initialized: AtomicBool,
|
initialized: AtomicBool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +58,26 @@ impl AtxKeyExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a new executor with a pre-opened shared serial handle.
|
||||||
|
pub fn new_with_shared_serial(config: AtxKeyConfig, serial_handle: SharedSerialHandle) -> Self {
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
gpio_handle: Mutex::new(None),
|
||||||
|
usb_relay_handle: Mutex::new(None),
|
||||||
|
serial_handle: Mutex::new(Some(serial_handle)),
|
||||||
|
initialized: AtomicBool::new(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open a serial relay device and wrap it for shared use.
|
||||||
|
pub fn open_shared_serial(device: &str, baud_rate: u32) -> Result<SharedSerialHandle> {
|
||||||
|
let port = serialport::new(device, baud_rate)
|
||||||
|
.timeout(Duration::from_millis(100))
|
||||||
|
.open()
|
||||||
|
.map_err(|e| AppError::Internal(format!("Serial port open failed: {}", e)))?;
|
||||||
|
Ok(Arc::new(Mutex::new(port)))
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if this executor is configured
|
/// Check if this executor is configured
|
||||||
pub fn is_configured(&self) -> bool {
|
pub fn is_configured(&self) -> bool {
|
||||||
self.config.is_configured()
|
self.config.is_configured()
|
||||||
@@ -181,14 +203,11 @@ impl AtxKeyExecutor {
|
|||||||
self.config.device, self.config.pin
|
self.config.device, self.config.pin
|
||||||
);
|
);
|
||||||
|
|
||||||
let baud_rate = self.config.baud_rate;
|
let existing_handle = self.serial_handle.lock().unwrap().as_ref().cloned();
|
||||||
|
if existing_handle.is_none() {
|
||||||
let port = serialport::new(&self.config.device, baud_rate)
|
let shared = Self::open_shared_serial(&self.config.device, self.config.baud_rate)?;
|
||||||
.timeout(Duration::from_millis(100))
|
*self.serial_handle.lock().unwrap() = Some(shared);
|
||||||
.open()
|
}
|
||||||
.map_err(|e| AppError::Internal(format!("Serial port open failed: {}", e)))?;
|
|
||||||
|
|
||||||
*self.serial_handle.lock().unwrap() = Some(port);
|
|
||||||
|
|
||||||
// Ensure relay is off initially
|
// Ensure relay is off initially
|
||||||
self.send_serial_relay_command(false)?;
|
self.send_serial_relay_command(false)?;
|
||||||
@@ -337,10 +356,14 @@ impl AtxKeyExecutor {
|
|||||||
// OFF: A0 01 00 A1
|
// OFF: A0 01 00 A1
|
||||||
let cmd = [0xA0, channel, state, checksum];
|
let cmd = [0xA0, channel, state, checksum];
|
||||||
|
|
||||||
let mut guard = self.serial_handle.lock().unwrap();
|
let serial_handle = self
|
||||||
let port = guard
|
.serial_handle
|
||||||
.as_mut()
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.as_ref()
|
||||||
|
.cloned()
|
||||||
.ok_or_else(|| AppError::Internal("Serial relay not initialized".to_string()))?;
|
.ok_or_else(|| AppError::Internal("Serial relay not initialized".to_string()))?;
|
||||||
|
let mut port = serial_handle.lock().unwrap();
|
||||||
|
|
||||||
port.write_all(&cmd)
|
port.write_all(&cmd)
|
||||||
.map_err(|e| AppError::Internal(format!("Serial relay write failed: {}", e)))?;
|
.map_err(|e| AppError::Internal(format!("Serial relay write failed: {}", e)))?;
|
||||||
|
|||||||
@@ -97,12 +97,14 @@ pub struct AudioFrame {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AudioFrame {
|
impl AudioFrame {
|
||||||
pub fn new(data: Bytes, config: &AudioConfig, sequence: u64) -> Self {
|
/// One capture block: `sample_rate` must be the **hardware** rate (e.g. ALSA `actual_rate`).
|
||||||
|
pub fn new_interleaved(data: Bytes, channels: u32, sample_rate: u32, sequence: u64) -> Self {
|
||||||
|
let bps = 2 * channels;
|
||||||
Self {
|
Self {
|
||||||
samples: data.len() as u32 / config.bytes_per_sample(),
|
samples: data.len() as u32 / bps,
|
||||||
data,
|
data,
|
||||||
sample_rate: config.sample_rate,
|
sample_rate,
|
||||||
channels: config.channels,
|
channels,
|
||||||
sequence,
|
sequence,
|
||||||
timestamp: Instant::now(),
|
timestamp: Instant::now(),
|
||||||
}
|
}
|
||||||
@@ -285,10 +287,17 @@ fn run_capture(
|
|||||||
.map(|h| h.get_rate().unwrap_or(config.sample_rate))
|
.map(|h| h.get_rate().unwrap_or(config.sample_rate))
|
||||||
.unwrap_or(config.sample_rate);
|
.unwrap_or(config.sample_rate);
|
||||||
|
|
||||||
|
if actual_rate != config.sample_rate {
|
||||||
|
info!(
|
||||||
|
"ALSA sample rate differs from requested ({}Hz vs {}Hz); streamer will resample to 48000Hz for Opus",
|
||||||
|
actual_rate, config.sample_rate
|
||||||
|
);
|
||||||
|
} else {
|
||||||
info!(
|
info!(
|
||||||
"Audio capture configured: {}Hz {}ch (requested {}Hz)",
|
"Audio capture configured: {}Hz {}ch (requested {}Hz)",
|
||||||
actual_rate, config.channels, config.sample_rate
|
actual_rate, config.channels, config.sample_rate
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Prepare for capture
|
// Prepare for capture
|
||||||
pcm.prepare()
|
pcm.prepare()
|
||||||
@@ -296,9 +305,17 @@ fn run_capture(
|
|||||||
|
|
||||||
let _ = state.send(CaptureState::Running);
|
let _ = state.send(CaptureState::Running);
|
||||||
|
|
||||||
// Allocate buffer - use u8 directly for zero-copy
|
// Sized from actual period — `readi` may return up to ~one period of frames per call.
|
||||||
let frame_bytes = config.bytes_per_frame();
|
let period_frames = pcm
|
||||||
let mut buffer = vec![0u8; frame_bytes];
|
.hw_params_current()
|
||||||
|
.ok()
|
||||||
|
.and_then(|h| h.get_period_size().ok())
|
||||||
|
.map(|f| f as usize)
|
||||||
|
.unwrap_or(1024)
|
||||||
|
.max(256);
|
||||||
|
let buf_frames = period_frames.saturating_mul(4).max(2048);
|
||||||
|
let bytes_per_frame = (config.channels as usize) * 2;
|
||||||
|
let mut buffer = vec![0u8; buf_frames * bytes_per_frame];
|
||||||
|
|
||||||
// Capture loop
|
// Capture loop
|
||||||
while !stop_flag.load(Ordering::Relaxed) {
|
while !stop_flag.load(Ordering::Relaxed) {
|
||||||
@@ -337,8 +354,12 @@ fn run_capture(
|
|||||||
|
|
||||||
// Directly use the buffer slice (already in correct byte format)
|
// Directly use the buffer slice (already in correct byte format)
|
||||||
let seq = sequence.fetch_add(1, Ordering::Relaxed);
|
let seq = sequence.fetch_add(1, Ordering::Relaxed);
|
||||||
let frame =
|
let frame = AudioFrame::new_interleaved(
|
||||||
AudioFrame::new(Bytes::copy_from_slice(&buffer[..byte_count]), config, seq);
|
Bytes::copy_from_slice(&buffer[..byte_count]),
|
||||||
|
config.channels,
|
||||||
|
actual_rate,
|
||||||
|
seq,
|
||||||
|
);
|
||||||
|
|
||||||
// Send to subscribers
|
// Send to subscribers
|
||||||
if frame_tx.receiver_count() > 0 {
|
if frame_tx.receiver_count() > 0 {
|
||||||
|
|||||||
@@ -381,7 +381,7 @@ impl AudioController {
|
|||||||
pub async fn update_config(&self, new_config: AudioControllerConfig) -> Result<()> {
|
pub async fn update_config(&self, new_config: AudioControllerConfig) -> Result<()> {
|
||||||
let was_streaming = self.is_streaming().await;
|
let was_streaming = self.is_streaming().await;
|
||||||
|
|
||||||
// Stop streaming if running
|
// Stop streaming if running (device/quality/enabled may all change)
|
||||||
if was_streaming {
|
if was_streaming {
|
||||||
self.stop_streaming().await?;
|
self.stop_streaming().await?;
|
||||||
}
|
}
|
||||||
@@ -389,8 +389,10 @@ impl AudioController {
|
|||||||
// Update config
|
// Update config
|
||||||
*self.config.write().await = new_config.clone();
|
*self.config.write().await = new_config.clone();
|
||||||
|
|
||||||
// Restart streaming if it was running and still enabled
|
// Start whenever audio is enabled — not only when we were already streaming.
|
||||||
if was_streaming && new_config.enabled {
|
// Otherwise PATCH /config/audio alone leaves enabled=true with no capture until
|
||||||
|
// POST /audio/start, which races WebRTC reconnect and matches "apply twice" reports.
|
||||||
|
if new_config.enabled {
|
||||||
self.start_streaming().await?;
|
self.start_streaming().await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ pub mod controller;
|
|||||||
pub mod device;
|
pub mod device;
|
||||||
pub mod encoder;
|
pub mod encoder;
|
||||||
pub mod monitor;
|
pub mod monitor;
|
||||||
|
pub mod resample;
|
||||||
pub mod streamer;
|
pub mod streamer;
|
||||||
|
|
||||||
pub use capture::{AudioCapturer, AudioConfig, AudioFrame};
|
pub use capture::{AudioCapturer, AudioConfig, AudioFrame};
|
||||||
|
|||||||
202
src/audio/resample.rs
Normal file
202
src/audio/resample.rs
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
//! Resample capture PCM to 48 kHz stereo for Opus (fixed 20 ms / 960×2 samples).
|
||||||
|
|
||||||
|
const OUT_RATE: f64 = 48000.0;
|
||||||
|
const OPUS_STEREO_SAMPLES: usize = 960 * 2;
|
||||||
|
|
||||||
|
enum PipelineState {
|
||||||
|
/// Native 48 kHz interleaved stereo: only buffer and slice into 20 ms blocks (no float work).
|
||||||
|
Stereo48kPassthrough,
|
||||||
|
/// Other rates / mono: linear interpolation to 48 kHz stereo.
|
||||||
|
Resample {
|
||||||
|
in_rate: u32,
|
||||||
|
in_channels: u32,
|
||||||
|
next_out_frame: u64,
|
||||||
|
buffer_start_frame: u64,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts incoming interleaved PCM to 48 kHz stereo, then exposes fixed 960×2-sample chunks.
|
||||||
|
pub struct Opus48kPcmBuffer {
|
||||||
|
state: PipelineState,
|
||||||
|
pending: Vec<i16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Opus48kPcmBuffer {
|
||||||
|
pub fn new(in_rate: u32, in_channels: u32) -> Self {
|
||||||
|
let ch = in_channels.max(1);
|
||||||
|
let rate = in_rate.max(1);
|
||||||
|
let state = if rate == 48000 && ch == 2 {
|
||||||
|
PipelineState::Stereo48kPassthrough
|
||||||
|
} else {
|
||||||
|
PipelineState::Resample {
|
||||||
|
in_rate: rate,
|
||||||
|
in_channels: ch,
|
||||||
|
next_out_frame: 0,
|
||||||
|
buffer_start_frame: 0,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
state,
|
||||||
|
pending: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when input is already 48 kHz stereo (no interpolation loop).
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn is_passthrough(&self) -> bool {
|
||||||
|
matches!(self.state, PipelineState::Stereo48kPassthrough)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append one capture block (`sample_rate` must match the rate this buffer was built for).
|
||||||
|
pub fn push_interleaved(&mut self, data: &[i16]) {
|
||||||
|
self.pending.extend_from_slice(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drain as many 960×2 stereo S16LE samples (20 ms @ 48 kHz) as possible.
|
||||||
|
pub fn pop_opus_frames(&mut self, out: &mut Vec<i16>) {
|
||||||
|
match &mut self.state {
|
||||||
|
PipelineState::Stereo48kPassthrough => {
|
||||||
|
while self.pending.len() >= OPUS_STEREO_SAMPLES {
|
||||||
|
out.extend_from_slice(&self.pending[..OPUS_STEREO_SAMPLES]);
|
||||||
|
self.pending.drain(..OPUS_STEREO_SAMPLES);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PipelineState::Resample {
|
||||||
|
in_rate,
|
||||||
|
in_channels,
|
||||||
|
next_out_frame,
|
||||||
|
buffer_start_frame,
|
||||||
|
} => {
|
||||||
|
let ch = *in_channels as usize;
|
||||||
|
if ch == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let batch_start = *next_out_frame;
|
||||||
|
let mut block = Vec::with_capacity(OPUS_STEREO_SAMPLES);
|
||||||
|
let mut complete = true;
|
||||||
|
|
||||||
|
for i in 0u64..960 {
|
||||||
|
let k = batch_start + i;
|
||||||
|
let p_abs = (k as f64) * (*in_rate as f64) / OUT_RATE;
|
||||||
|
let f_abs = p_abs.floor() as u64;
|
||||||
|
let frac = p_abs - f_abs as f64;
|
||||||
|
|
||||||
|
let f_rel = f_abs.saturating_sub(*buffer_start_frame) as usize;
|
||||||
|
if f_rel + 1 >= self.pending.len() / ch {
|
||||||
|
complete = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let base0 = f_rel * ch;
|
||||||
|
let base1 = (f_rel + 1) * ch;
|
||||||
|
|
||||||
|
let (l, r) = if *in_channels >= 2 {
|
||||||
|
let l0 = self.pending[base0] as f64;
|
||||||
|
let l1 = self.pending[base1] as f64;
|
||||||
|
let r0 = self.pending[base0 + 1] as f64;
|
||||||
|
let r1 = self.pending[base1 + 1] as f64;
|
||||||
|
(l0 + frac * (l1 - l0), r0 + frac * (r1 - r0))
|
||||||
|
} else {
|
||||||
|
let m0 = self.pending[base0] as f64;
|
||||||
|
let m1 = self.pending[base1] as f64;
|
||||||
|
let v = m0 + frac * (m1 - m0);
|
||||||
|
(v, v)
|
||||||
|
};
|
||||||
|
|
||||||
|
block.push(clamp_f64_to_i16(l));
|
||||||
|
block.push(clamp_f64_to_i16(r));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !complete || block.len() != OPUS_STEREO_SAMPLES {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
out.extend_from_slice(&block);
|
||||||
|
*next_out_frame = batch_start + 960;
|
||||||
|
trim_resample_prefix(
|
||||||
|
&mut self.pending,
|
||||||
|
*in_rate,
|
||||||
|
*next_out_frame,
|
||||||
|
buffer_start_frame,
|
||||||
|
ch,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trim_resample_prefix(
|
||||||
|
pending: &mut Vec<i16>,
|
||||||
|
in_rate: u32,
|
||||||
|
next_out_frame: u64,
|
||||||
|
buffer_start_frame: &mut u64,
|
||||||
|
ch: usize,
|
||||||
|
) {
|
||||||
|
if pending.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let p_next = (next_out_frame as f64) * (in_rate as f64) / OUT_RATE;
|
||||||
|
let need_abs = p_next.floor() as u64;
|
||||||
|
let keep_from_abs = need_abs.saturating_sub(1);
|
||||||
|
if keep_from_abs <= *buffer_start_frame {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let drop_frames = (keep_from_abs - *buffer_start_frame) as usize;
|
||||||
|
let drop_samples = drop_frames.saturating_mul(ch).min(pending.len());
|
||||||
|
if drop_samples > 0 {
|
||||||
|
pending.drain(0..drop_samples);
|
||||||
|
*buffer_start_frame += drop_frames as u64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[inline]
|
||||||
|
fn clamp_f64_to_i16(v: f64) -> i16 {
|
||||||
|
v.round().clamp(i16::MIN as f64, i16::MAX as f64) as i16
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn passthrough_48k_identity_tone_length() {
|
||||||
|
let mut buf = Opus48kPcmBuffer::new(48000, 2);
|
||||||
|
assert!(buf.is_passthrough());
|
||||||
|
let mut chunk = vec![0i16; 960 * 2];
|
||||||
|
for i in 0..960 {
|
||||||
|
let s = (i as f32 * 0.1).sin() * 3000.0;
|
||||||
|
chunk[2 * i] = s as i16;
|
||||||
|
chunk[2 * i + 1] = s as i16;
|
||||||
|
}
|
||||||
|
buf.push_interleaved(&chunk);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
buf.pop_opus_frames(&mut out);
|
||||||
|
assert_eq!(out.len(), 960 * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upsample_44k_to_48k_chunk() {
|
||||||
|
let mut buf = Opus48kPcmBuffer::new(44100, 2);
|
||||||
|
assert!(!buf.is_passthrough());
|
||||||
|
let mut chunk = vec![0i16; 882 * 2];
|
||||||
|
for i in 0..882 {
|
||||||
|
chunk[2 * i] = (i as i16).wrapping_mul(10);
|
||||||
|
chunk[2 * i + 1] = (i as i16).wrapping_mul(-7);
|
||||||
|
}
|
||||||
|
buf.push_interleaved(&chunk);
|
||||||
|
let mut out = Vec::new();
|
||||||
|
buf.pop_opus_frames(&mut out);
|
||||||
|
assert_eq!(out.len(), 960 * 2, "expected one 20ms Opus block");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mono_48k_not_passthrough() {
|
||||||
|
let buf = Opus48kPcmBuffer::new(48000, 1);
|
||||||
|
assert!(!buf.is_passthrough());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,9 +9,12 @@ use std::time::Instant;
|
|||||||
use tokio::sync::{broadcast, watch, Mutex, RwLock};
|
use tokio::sync::{broadcast, watch, Mutex, RwLock};
|
||||||
use tracing::{error, info, warn};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
use super::capture::{AudioCapturer, AudioConfig, CaptureState};
|
use super::capture::{AudioCapturer, AudioConfig, AudioFrame, CaptureState};
|
||||||
use super::encoder::{OpusConfig, OpusEncoder, OpusFrame};
|
use super::encoder::{OpusConfig, OpusEncoder, OpusFrame};
|
||||||
|
use super::resample::Opus48kPcmBuffer;
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
|
use bytemuck;
|
||||||
|
use bytes::Bytes;
|
||||||
|
|
||||||
/// Audio stream state
|
/// Audio stream state
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
@@ -254,6 +257,9 @@ impl AudioStreamer {
|
|||||||
|
|
||||||
info!("Audio stream task started");
|
info!("Audio stream task started");
|
||||||
|
|
||||||
|
let mut to_48k: Option<Opus48kPcmBuffer> = None;
|
||||||
|
let mut queued_48k: Vec<i16> = Vec::new();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Check stop flag (atomic, no async lock needed)
|
// Check stop flag (atomic, no async lock needed)
|
||||||
if stop_flag.load(Ordering::Relaxed) {
|
if stop_flag.load(Ordering::Relaxed) {
|
||||||
@@ -273,17 +279,45 @@ impl AudioStreamer {
|
|||||||
|
|
||||||
match recv_result {
|
match recv_result {
|
||||||
Ok(Ok(audio_frame)) => {
|
Ok(Ok(audio_frame)) => {
|
||||||
// Encode to Opus
|
if to_48k.is_none() {
|
||||||
|
to_48k = Some(Opus48kPcmBuffer::new(
|
||||||
|
audio_frame.sample_rate,
|
||||||
|
audio_frame.channels,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let pipeline = match to_48k.as_mut() {
|
||||||
|
Some(p) => p,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let samples: &[i16] = match bytemuck::try_cast_slice(&audio_frame.data) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(_) => {
|
||||||
|
warn!("Audio frame size not multiple of 2; skipping");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !samples.is_empty() {
|
||||||
|
pipeline.push_interleaved(samples);
|
||||||
|
}
|
||||||
|
pipeline.pop_opus_frames(&mut queued_48k);
|
||||||
|
|
||||||
|
while queued_48k.len() >= 960 * 2 {
|
||||||
|
let pcm_20ms =
|
||||||
|
Bytes::copy_from_slice(bytemuck::cast_slice(&queued_48k[..960 * 2]));
|
||||||
|
queued_48k.drain(..960 * 2);
|
||||||
|
|
||||||
|
let frame_48k = AudioFrame::new_interleaved(pcm_20ms, 2, 48000, 0);
|
||||||
|
|
||||||
let opus_result = {
|
let opus_result = {
|
||||||
let mut enc_guard = encoder.lock().await;
|
let mut enc_guard = encoder.lock().await;
|
||||||
(*enc_guard)
|
(*enc_guard)
|
||||||
.as_mut()
|
.as_mut()
|
||||||
.map(|enc| enc.encode_frame(&audio_frame))
|
.map(|enc| enc.encode_frame(&frame_48k))
|
||||||
};
|
};
|
||||||
|
|
||||||
match opus_result {
|
match opus_result {
|
||||||
Some(Ok(opus_frame)) => {
|
Some(Ok(opus_frame)) => {
|
||||||
// Publish latest frame to subscribers
|
|
||||||
if opus_tx.receiver_count() > 0 {
|
if opus_tx.receiver_count() > 0 {
|
||||||
let _ = opus_tx.send(Some(Arc::new(opus_frame)));
|
let _ = opus_tx.send(Some(Arc::new(opus_frame)));
|
||||||
}
|
}
|
||||||
@@ -297,6 +331,7 @@ impl AudioStreamer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Ok(Err(broadcast::error::RecvError::Closed)) => {
|
Ok(Err(broadcast::error::RecvError::Closed)) => {
|
||||||
info!("Audio capture channel closed");
|
info!("Audio capture channel closed");
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -126,6 +126,15 @@ impl SessionStore {
|
|||||||
Ok(result.rows_affected())
|
Ok(result.rows_affected())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete all sessions for a specific user
|
||||||
|
pub async fn delete_by_user_id(&self, user_id: &str) -> Result<u64> {
|
||||||
|
let result = sqlx::query("DELETE FROM sessions WHERE user_id = ?1")
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(result.rows_affected())
|
||||||
|
}
|
||||||
|
|
||||||
/// List all session IDs
|
/// List all session IDs
|
||||||
pub async fn list_ids(&self) -> Result<Vec<String>> {
|
pub async fn list_ids(&self) -> Result<Vec<String>> {
|
||||||
let rows: Vec<(String,)> = sqlx::query_as("SELECT id FROM sessions")
|
let rows: Vec<(String,)> = sqlx::query_as("SELECT id FROM sessions")
|
||||||
|
|||||||
@@ -703,7 +703,9 @@ impl StreamConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Web server configuration
|
/// Web server configuration persisted in the database (includes on-disk TLS paths).
|
||||||
|
///
|
||||||
|
/// The HTTP API for `/api/config/web` uses `WebConfigResponse` instead: no path fields, includes `has_custom_cert`.
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use std::path::Path;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
use super::AppConfig;
|
use super::AppConfig;
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
@@ -18,6 +19,8 @@ pub struct ConfigStore {
|
|||||||
/// Lock-free cache using ArcSwap for zero-cost reads
|
/// Lock-free cache using ArcSwap for zero-cost reads
|
||||||
cache: Arc<ArcSwap<AppConfig>>,
|
cache: Arc<ArcSwap<AppConfig>>,
|
||||||
change_tx: broadcast::Sender<ConfigChange>,
|
change_tx: broadcast::Sender<ConfigChange>,
|
||||||
|
/// Serializes `set` / `update` so concurrent PATCH handlers cannot clobber each other
|
||||||
|
write_lock: Arc<Mutex<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Configuration change event
|
/// Configuration change event
|
||||||
@@ -59,6 +62,7 @@ impl ConfigStore {
|
|||||||
pool,
|
pool,
|
||||||
cache,
|
cache,
|
||||||
change_tx,
|
change_tx,
|
||||||
|
write_lock: Arc::new(Mutex::new(())),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +195,7 @@ impl ConfigStore {
|
|||||||
|
|
||||||
/// Set entire configuration
|
/// Set entire configuration
|
||||||
pub async fn set(&self, config: AppConfig) -> Result<()> {
|
pub async fn set(&self, config: AppConfig) -> Result<()> {
|
||||||
|
let _guard = self.write_lock.lock().await;
|
||||||
Self::save_config_to_db(&self.pool, &config).await?;
|
Self::save_config_to_db(&self.pool, &config).await?;
|
||||||
self.cache.store(Arc::new(config));
|
self.cache.store(Arc::new(config));
|
||||||
|
|
||||||
@@ -204,13 +209,13 @@ impl ConfigStore {
|
|||||||
|
|
||||||
/// Update configuration with a closure
|
/// Update configuration with a closure
|
||||||
///
|
///
|
||||||
/// Note: This uses a read-modify-write pattern. For concurrent updates,
|
/// Uses read-modify-write under a mutex so concurrent `update` / `set` calls are serialized
|
||||||
/// the last write wins. This is acceptable for configuration changes
|
/// and merged correctly (each closure sees the latest stored config).
|
||||||
/// which are infrequent and typically user-initiated.
|
|
||||||
pub async fn update<F>(&self, f: F) -> Result<()>
|
pub async fn update<F>(&self, f: F) -> Result<()>
|
||||||
where
|
where
|
||||||
F: FnOnce(&mut AppConfig),
|
F: FnOnce(&mut AppConfig),
|
||||||
{
|
{
|
||||||
|
let _guard = self.write_lock.lock().await;
|
||||||
// Load current config, clone it for modification
|
// Load current config, clone it for modification
|
||||||
let current = self.cache.load();
|
let current = self.cache.load();
|
||||||
let mut config = (**current).clone();
|
let mut config = (**current).clone();
|
||||||
|
|||||||
@@ -280,6 +280,9 @@ impl ExtensionManager {
|
|||||||
|
|
||||||
ExtensionId::Gostc => {
|
ExtensionId::Gostc => {
|
||||||
let c = &config.gostc;
|
let c = &config.gostc;
|
||||||
|
if c.addr.trim().is_empty() {
|
||||||
|
return Err("GOSTC server address is required".into());
|
||||||
|
}
|
||||||
if c.key.is_empty() {
|
if c.key.is_empty() {
|
||||||
return Err("GOSTC client key is required".into());
|
return Err("GOSTC client key is required".into());
|
||||||
}
|
}
|
||||||
@@ -291,10 +294,8 @@ impl ExtensionManager {
|
|||||||
args.push("--tls=true".to_string());
|
args.push("--tls=true".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add server address
|
// Server address (validated non-empty above)
|
||||||
if !c.addr.is_empty() {
|
args.extend(["-addr".to_string(), c.addr.trim().to_string()]);
|
||||||
args.extend(["-addr".to_string(), c.addr.clone()]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add client key
|
// Add client key
|
||||||
args.extend(["-key".to_string(), c.key.clone()]);
|
args.extend(["-key".to_string(), c.key.clone()]);
|
||||||
@@ -375,7 +376,11 @@ impl ExtensionManager {
|
|||||||
.filter_map(|id| {
|
.filter_map(|id| {
|
||||||
let should_run = match id {
|
let should_run = match id {
|
||||||
ExtensionId::Ttyd => config.ttyd.enabled,
|
ExtensionId::Ttyd => config.ttyd.enabled,
|
||||||
ExtensionId::Gostc => config.gostc.enabled && !config.gostc.key.is_empty(),
|
ExtensionId::Gostc => {
|
||||||
|
config.gostc.enabled
|
||||||
|
&& !config.gostc.key.is_empty()
|
||||||
|
&& !config.gostc.addr.trim().is_empty()
|
||||||
|
}
|
||||||
ExtensionId::Easytier => {
|
ExtensionId::Easytier => {
|
||||||
config.easytier.enabled && !config.easytier.network_name.is_empty()
|
config.easytier.enabled && !config.easytier.network_name.is_empty()
|
||||||
}
|
}
|
||||||
@@ -435,6 +440,7 @@ impl ExtensionManager {
|
|||||||
|
|
||||||
if config.gostc.enabled
|
if config.gostc.enabled
|
||||||
&& !config.gostc.key.is_empty()
|
&& !config.gostc.key.is_empty()
|
||||||
|
&& !config.gostc.addr.trim().is_empty()
|
||||||
&& self.check_available(ExtensionId::Gostc)
|
&& self.check_available(ExtensionId::Gostc)
|
||||||
{
|
{
|
||||||
start_futures.push(Box::pin(async {
|
start_futures.push(Box::pin(async {
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ impl Default for TtydConfig {
|
|||||||
pub struct GostcConfig {
|
pub struct GostcConfig {
|
||||||
/// Enable auto-start
|
/// Enable auto-start
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
/// Server address (e.g., gostc.mofeng.run)
|
/// Server address (hostname or IP)
|
||||||
pub addr: String,
|
pub addr: String,
|
||||||
/// Client key from GOSTC management panel
|
/// Client key from GOSTC management panel
|
||||||
#[serde(skip_serializing_if = "String::is_empty")]
|
#[serde(skip_serializing_if = "String::is_empty")]
|
||||||
@@ -134,7 +134,7 @@ impl Default for GostcConfig {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
addr: "gostc.mofeng.run".to_string(),
|
addr: String::new(),
|
||||||
key: String::new(),
|
key: String::new(),
|
||||||
tls: true,
|
tls: true,
|
||||||
}
|
}
|
||||||
|
|||||||
108
src/main.rs
108
src/main.rs
@@ -1,10 +1,11 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
use std::io::Write;
|
||||||
use std::net::{IpAddr, SocketAddr};
|
use std::net::{IpAddr, SocketAddr};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use axum_server::tls_rustls::RustlsConfig;
|
use axum_server::tls_rustls::RustlsConfig;
|
||||||
use clap::{Parser, ValueEnum};
|
use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||||
use futures::{stream::FuturesUnordered, StreamExt};
|
use futures::{stream::FuturesUnordered, StreamExt};
|
||||||
use rustls::crypto::{ring, CryptoProvider};
|
use rustls::crypto::{ring, CryptoProvider};
|
||||||
use tokio::sync::{broadcast, mpsc};
|
use tokio::sync::{broadcast, mpsc};
|
||||||
@@ -49,6 +50,10 @@ enum LogLevel {
|
|||||||
#[command(name = "one-kvm")]
|
#[command(name = "one-kvm")]
|
||||||
#[command(version, about = "A open and lightweight IP-KVM solution", long_about = None)]
|
#[command(version, about = "A open and lightweight IP-KVM solution", long_about = None)]
|
||||||
struct CliArgs {
|
struct CliArgs {
|
||||||
|
/// User management commands
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Option<CliCommand>,
|
||||||
|
|
||||||
/// Listen address (overrides database config)
|
/// Listen address (overrides database config)
|
||||||
#[arg(short = 'a', long, value_name = "ADDRESS")]
|
#[arg(short = 'a', long, value_name = "ADDRESS")]
|
||||||
address: Option<String>,
|
address: Option<String>,
|
||||||
@@ -86,6 +91,24 @@ struct CliArgs {
|
|||||||
verbose: u8,
|
verbose: u8,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
enum CliCommand {
|
||||||
|
/// Manage local users
|
||||||
|
User(UserCommand),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
struct UserCommand {
|
||||||
|
#[command(subcommand)]
|
||||||
|
action: UserAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
enum UserAction {
|
||||||
|
/// Set password for the single local user (interactive terminal prompt)
|
||||||
|
SetPassword,
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> anyhow::Result<()> {
|
async fn main() -> anyhow::Result<()> {
|
||||||
// Parse command line arguments
|
// Parse command line arguments
|
||||||
@@ -101,9 +124,15 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
tracing::info!("Starting One-KVM v{}", env!("CARGO_PKG_VERSION"));
|
tracing::info!("Starting One-KVM v{}", env!("CARGO_PKG_VERSION"));
|
||||||
|
|
||||||
// Determine data directory (CLI arg takes precedence)
|
// Determine data directory (CLI arg takes precedence)
|
||||||
let data_dir = args.data_dir.unwrap_or_else(get_data_dir);
|
let data_dir = args.data_dir.clone().unwrap_or_else(get_data_dir);
|
||||||
tracing::info!("Data directory: {}", data_dir.display());
|
tracing::info!("Data directory: {}", data_dir.display());
|
||||||
|
|
||||||
|
// Run one-off CLI command and exit.
|
||||||
|
if let Some(command) = args.command {
|
||||||
|
run_cli_command(command, data_dir).await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure data directory exists
|
// Ensure data directory exists
|
||||||
tokio::fs::create_dir_all(&data_dir).await?;
|
tokio::fs::create_dir_all(&data_dir).await?;
|
||||||
|
|
||||||
@@ -765,6 +794,81 @@ fn get_data_dir() -> PathBuf {
|
|||||||
PathBuf::from("/etc/one-kvm")
|
PathBuf::from("/etc/one-kvm")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn run_cli_command(command: CliCommand, data_dir: PathBuf) -> anyhow::Result<()> {
|
||||||
|
tokio::fs::create_dir_all(&data_dir).await?;
|
||||||
|
let db_path = data_dir.join("one-kvm.db");
|
||||||
|
let config_store = ConfigStore::new(&db_path).await?;
|
||||||
|
let users = UserStore::new(config_store.pool().clone());
|
||||||
|
let sessions = SessionStore::new(config_store.pool().clone(), 0);
|
||||||
|
|
||||||
|
match command {
|
||||||
|
CliCommand::User(user) => run_user_action(user.action, &users, &sessions).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_user_action(
|
||||||
|
action: UserAction,
|
||||||
|
users: &UserStore,
|
||||||
|
sessions: &SessionStore,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
match action {
|
||||||
|
UserAction::SetPassword => set_user_password(users, sessions).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_user_password(users: &UserStore, sessions: &SessionStore) -> anyhow::Result<()> {
|
||||||
|
let all = users.list().await?;
|
||||||
|
let user = match all.len() {
|
||||||
|
0 => anyhow::bail!("No local user exists yet; complete setup in the web UI first."),
|
||||||
|
1 => &all[0],
|
||||||
|
_ => anyhow::bail!(
|
||||||
|
"Expected exactly one local user (single-user design), found {}. Remove extra users from the database or contact support.",
|
||||||
|
all.len()
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_password = read_new_password_interactive()?;
|
||||||
|
if new_password.len() < 4 {
|
||||||
|
anyhow::bail!("Password must be at least 4 characters");
|
||||||
|
}
|
||||||
|
|
||||||
|
users.update_password(&user.id, &new_password).await?;
|
||||||
|
let revoked = sessions.delete_by_user_id(&user.id).await?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
"Password updated for user '{}' and {} sessions revoked",
|
||||||
|
user.username,
|
||||||
|
revoked
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
"Password updated for user '{}' (revoked {} sessions).",
|
||||||
|
user.username, revoked
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_new_password_interactive() -> anyhow::Result<String> {
|
||||||
|
let once = |label: &str| -> anyhow::Result<String> {
|
||||||
|
print!("{}", label);
|
||||||
|
std::io::stdout().flush()?;
|
||||||
|
|
||||||
|
let mut line = String::new();
|
||||||
|
std::io::stdin().read_line(&mut line)?;
|
||||||
|
let s = line.trim_end_matches(['\r', '\n']).to_string();
|
||||||
|
if s.is_empty() {
|
||||||
|
anyhow::bail!("Password cannot be empty");
|
||||||
|
}
|
||||||
|
Ok(s)
|
||||||
|
};
|
||||||
|
|
||||||
|
let a = once("New password: ")?;
|
||||||
|
let b = once("Confirm password: ")?;
|
||||||
|
if a != b {
|
||||||
|
anyhow::bail!("Passwords do not match");
|
||||||
|
}
|
||||||
|
Ok(a)
|
||||||
|
}
|
||||||
|
|
||||||
/// Resolve bind IPs from config, preferring bind_addresses when set.
|
/// Resolve bind IPs from config, preferring bind_addresses when set.
|
||||||
fn resolve_bind_addresses(web: &config::WebConfig) -> anyhow::Result<Vec<IpAddr>> {
|
fn resolve_bind_addresses(web: &config::WebConfig) -> anyhow::Result<Vec<IpAddr>> {
|
||||||
let raw_addrs = if !web.bind_addresses.is_empty() {
|
let raw_addrs = if !web.bind_addresses.is_empty() {
|
||||||
|
|||||||
@@ -498,6 +498,9 @@ async fn stream_video_interleaved(
|
|||||||
let mut h265_payloader = H265Payloader::new();
|
let mut h265_payloader = H265Payloader::new();
|
||||||
let mut ctrl_read_buf = [0u8; RTSP_BUF_SIZE];
|
let mut ctrl_read_buf = [0u8; RTSP_BUF_SIZE];
|
||||||
let mut ctrl_buffer = Vec::with_capacity(RTSP_BUF_SIZE);
|
let mut ctrl_buffer = Vec::with_capacity(RTSP_BUF_SIZE);
|
||||||
|
// RTP timestamps must increase; pts_ms is often 0 for many frames (capture→encode jitter),
|
||||||
|
// which yields a flat RTP timestamp and breaks VLC/ffplay.
|
||||||
|
let mut last_rtp_timestamp: u32 = 0;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
@@ -529,7 +532,11 @@ async fn stream_video_interleaved(
|
|||||||
update_parameter_sets(&mut params, &frame);
|
update_parameter_sets(&mut params, &frame);
|
||||||
}
|
}
|
||||||
|
|
||||||
let rtp_timestamp = pts_to_rtp_timestamp(frame.pts_ms);
|
let rtp_timestamp = monotonic_rtp_timestamp(
|
||||||
|
frame.pts_ms,
|
||||||
|
&mut last_rtp_timestamp,
|
||||||
|
frame.duration,
|
||||||
|
);
|
||||||
|
|
||||||
let payloads: Vec<Bytes> = match rtsp_codec {
|
let payloads: Vec<Bytes> = match rtsp_codec {
|
||||||
RtspCodec::H264 => h264_payloader
|
RtspCodec::H264 => h264_payloader
|
||||||
@@ -1128,6 +1135,25 @@ fn pts_to_rtp_timestamp(pts_ms: i64) -> u32 {
|
|||||||
((pts_ms as u64 * RTP_CLOCK_RATE as u64) / 1000) as u32
|
((pts_ms as u64 * RTP_CLOCK_RATE as u64) / 1000) as u32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 90 kHz ticks per frame from nominal duration (at least 1).
|
||||||
|
fn rtp_timestamp_increment(frame_duration: Duration) -> u32 {
|
||||||
|
let inc = (frame_duration.as_secs_f64() * f64::from(RTP_CLOCK_RATE)).round() as u32;
|
||||||
|
inc.max(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prefer PTS-based RTP time when it advances; otherwise step by `frame_duration` in 90 kHz units.
|
||||||
|
fn monotonic_rtp_timestamp(pts_ms: i64, last: &mut u32, frame_duration: Duration) -> u32 {
|
||||||
|
let from_pts = pts_to_rtp_timestamp(pts_ms);
|
||||||
|
let inc = rtp_timestamp_increment(frame_duration);
|
||||||
|
let ts = if from_pts > *last {
|
||||||
|
from_pts
|
||||||
|
} else {
|
||||||
|
last.wrapping_add(inc)
|
||||||
|
};
|
||||||
|
*last = ts;
|
||||||
|
ts
|
||||||
|
}
|
||||||
|
|
||||||
fn generate_session_id() -> String {
|
fn generate_session_id() -> String {
|
||||||
let mut rng = rand::rng();
|
let mut rng = rand::rng();
|
||||||
let value: u64 = rng.random();
|
let value: u64 = rng.random();
|
||||||
@@ -1199,6 +1225,28 @@ mod tests {
|
|||||||
assert_eq!(response.status(), rtsp::StatusCode::MethodNotAllowed);
|
assert_eq!(response.status(), rtsp::StatusCode::MethodNotAllowed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn monotonic_rtp_timestamp_steps_when_pts_stays_zero() {
|
||||||
|
let d = Duration::from_millis(33);
|
||||||
|
let mut last = 0u32;
|
||||||
|
let a = monotonic_rtp_timestamp(0, &mut last, d);
|
||||||
|
let b = monotonic_rtp_timestamp(0, &mut last, d);
|
||||||
|
let c = monotonic_rtp_timestamp(0, &mut last, d);
|
||||||
|
assert!(a > 0);
|
||||||
|
assert!(b > a);
|
||||||
|
assert!(c > b);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn monotonic_rtp_timestamp_uses_pts_when_it_advances() {
|
||||||
|
let d = Duration::from_millis(33);
|
||||||
|
let mut last = 0u32;
|
||||||
|
let a = monotonic_rtp_timestamp(1000, &mut last, d);
|
||||||
|
assert_eq!(a, 90_000);
|
||||||
|
let b = monotonic_rtp_timestamp(2000, &mut last, d);
|
||||||
|
assert_eq!(b, 180_000);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_sdp_h264_is_parseable_with_expected_video_attributes() {
|
fn build_sdp_h264_is_parseable_with_expected_video_attributes() {
|
||||||
let config = RtspConfig::default();
|
let config = RtspConfig::default();
|
||||||
|
|||||||
@@ -3,20 +3,64 @@
|
|||||||
//! Manages video frame distribution and per-client statistics.
|
//! Manages video frame distribution and per-client statistics.
|
||||||
|
|
||||||
use arc_swap::ArcSwap;
|
use arc_swap::ArcSwap;
|
||||||
|
use bytes::Bytes;
|
||||||
use parking_lot::Mutex as ParkingMutex;
|
use parking_lot::Mutex as ParkingMutex;
|
||||||
use parking_lot::RwLock as ParkingRwLock;
|
use parking_lot::RwLock as ParkingRwLock;
|
||||||
use std::collections::{HashMap, VecDeque};
|
use std::collections::{HashMap, VecDeque};
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, OnceLock};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use crate::video::encoder::traits::{Encoder, EncoderConfig};
|
use crate::video::encoder::traits::{Encoder, EncoderConfig};
|
||||||
use crate::video::encoder::JpegEncoder;
|
use crate::video::encoder::JpegEncoder;
|
||||||
use crate::video::format::PixelFormat;
|
use crate::video::format::{PixelFormat, Resolution};
|
||||||
use crate::video::VideoFrame;
|
use crate::video::VideoFrame;
|
||||||
|
|
||||||
|
/// Cached "no signal" placeholder JPEG (640×360 dark-gray image).
|
||||||
|
/// Generated once on first use and reused for all NoSignal frames.
|
||||||
|
static NO_SIGNAL_JPEG: OnceLock<Bytes> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Generate a minimal "no signal" JPEG (640×360, dark gray background).
|
||||||
|
/// Uses turbojpeg directly to produce a valid JPEG without additional deps.
|
||||||
|
fn generate_no_signal_jpeg() -> Bytes {
|
||||||
|
const W: usize = 640;
|
||||||
|
const H: usize = 360;
|
||||||
|
|
||||||
|
let y_size = W * H;
|
||||||
|
let uv_size = y_size / 4;
|
||||||
|
let mut i420 = vec![0u8; y_size + uv_size * 2];
|
||||||
|
|
||||||
|
// Y = 32 (dark gray, above the 16 black floor so it is clearly visible)
|
||||||
|
i420[..y_size].fill(32);
|
||||||
|
// U and V = 128 (neutral chroma → no colour tint)
|
||||||
|
i420[y_size..].fill(128);
|
||||||
|
|
||||||
|
match turbojpeg::Compressor::new() {
|
||||||
|
Ok(mut compressor) => {
|
||||||
|
let _ = compressor.set_quality(70);
|
||||||
|
let yuv = turbojpeg::YuvImage {
|
||||||
|
pixels: i420.as_slice(),
|
||||||
|
width: W,
|
||||||
|
height: H,
|
||||||
|
align: 1,
|
||||||
|
subsamp: turbojpeg::Subsamp::Sub2x2,
|
||||||
|
};
|
||||||
|
match compressor.compress_yuv_to_vec(yuv) {
|
||||||
|
Ok(jpeg) => Bytes::from(jpeg),
|
||||||
|
Err(_) => Bytes::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => Bytes::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return a reference to the cached no-signal JPEG bytes.
|
||||||
|
fn no_signal_jpeg() -> &'static Bytes {
|
||||||
|
NO_SIGNAL_JPEG.get_or_init(generate_no_signal_jpeg)
|
||||||
|
}
|
||||||
|
|
||||||
/// Client ID type (UUID string)
|
/// Client ID type (UUID string)
|
||||||
pub type ClientId = String;
|
pub type ClientId = String;
|
||||||
|
|
||||||
@@ -318,6 +362,12 @@ impl MjpegStreamHandler {
|
|||||||
PixelFormat::Nv12 => encoder
|
PixelFormat::Nv12 => encoder
|
||||||
.encode_nv12(frame.data(), sequence)
|
.encode_nv12(frame.data(), sequence)
|
||||||
.map_err(|e| format!("NV12 encode failed: {}", e))?,
|
.map_err(|e| format!("NV12 encode failed: {}", e))?,
|
||||||
|
PixelFormat::Nv16 => encoder
|
||||||
|
.encode_nv16(frame.data(), sequence)
|
||||||
|
.map_err(|e| format!("NV16 encode failed: {}", e))?,
|
||||||
|
PixelFormat::Nv24 => encoder
|
||||||
|
.encode_nv24(frame.data(), sequence)
|
||||||
|
.map_err(|e| format!("NV24 encode failed: {}", e))?,
|
||||||
PixelFormat::Rgb24 => encoder
|
PixelFormat::Rgb24 => encoder
|
||||||
.encode_rgb(frame.data(), sequence)
|
.encode_rgb(frame.data(), sequence)
|
||||||
.map_err(|e| format!("RGB encode failed: {}", e))?,
|
.map_err(|e| format!("RGB encode failed: {}", e))?,
|
||||||
@@ -348,6 +398,34 @@ impl MjpegStreamHandler {
|
|||||||
let _ = self.frame_notify.send(());
|
let _ = self.frame_notify.send(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Push a "no signal" placeholder JPEG to all connected MJPEG clients.
|
||||||
|
///
|
||||||
|
/// Unlike `set_offline()`, this keeps the stream marked as **online** so
|
||||||
|
/// that HTTP clients remain connected and see the placeholder image instead
|
||||||
|
/// of a black/empty screen. Call this whenever the capture thread enters
|
||||||
|
/// the `NoSignal` state.
|
||||||
|
pub fn push_no_signal_placeholder(&self) {
|
||||||
|
let jpeg = no_signal_jpeg();
|
||||||
|
if jpeg.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let frame = VideoFrame::new(
|
||||||
|
jpeg.clone(),
|
||||||
|
Resolution::new(640, 360),
|
||||||
|
PixelFormat::Mjpeg,
|
||||||
|
0,
|
||||||
|
self.sequence.fetch_add(1, Ordering::Relaxed),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Store as current frame so late-joining clients get it immediately.
|
||||||
|
self.current_frame.store(Arc::new(Some(frame)));
|
||||||
|
// Ensure stream is marked online so the HTTP handler keeps iterating.
|
||||||
|
self.online.store(true, Ordering::SeqCst);
|
||||||
|
// Wake up waiting HTTP clients.
|
||||||
|
let _ = self.frame_notify.send(());
|
||||||
|
}
|
||||||
|
|
||||||
/// Set stream online (called when streaming starts)
|
/// Set stream online (called when streaming starts)
|
||||||
pub fn set_online(&self) {
|
pub fn set_online(&self) {
|
||||||
self.online.store(true, Ordering::SeqCst);
|
self.online.store(true, Ordering::SeqCst);
|
||||||
|
|||||||
@@ -190,87 +190,70 @@ pub struct PixelConverter {
|
|||||||
resolution: Resolution,
|
resolution: Resolution,
|
||||||
/// Output buffer (reused across conversions)
|
/// Output buffer (reused across conversions)
|
||||||
output_buffer: Yuv420pBuffer,
|
output_buffer: Yuv420pBuffer,
|
||||||
|
/// Scratch buffer for split chroma planes when converting semiplanar 4:2:2 / 4:4:4 input.
|
||||||
|
uv_split_buffer: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PixelConverter {
|
impl PixelConverter {
|
||||||
/// Create a new converter for YUYV → YUV420P
|
fn new(src_format: PixelFormat, dst_format: PixelFormat, resolution: Resolution) -> Self {
|
||||||
pub fn yuyv_to_yuv420p(resolution: Resolution) -> Self {
|
let max_uv_plane_size = (resolution.width * resolution.height) as usize;
|
||||||
Self {
|
Self {
|
||||||
src_format: PixelFormat::Yuyv,
|
src_format,
|
||||||
dst_format: PixelFormat::Yuv420,
|
dst_format,
|
||||||
resolution,
|
resolution,
|
||||||
output_buffer: Yuv420pBuffer::new(resolution),
|
output_buffer: Yuv420pBuffer::new(resolution),
|
||||||
|
uv_split_buffer: vec![0u8; max_uv_plane_size * 2],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a new converter for YUYV → YUV420P
|
||||||
|
pub fn yuyv_to_yuv420p(resolution: Resolution) -> Self {
|
||||||
|
Self::new(PixelFormat::Yuyv, PixelFormat::Yuv420, resolution)
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a new converter for UYVY → YUV420P
|
/// Create a new converter for UYVY → YUV420P
|
||||||
pub fn uyvy_to_yuv420p(resolution: Resolution) -> Self {
|
pub fn uyvy_to_yuv420p(resolution: Resolution) -> Self {
|
||||||
Self {
|
Self::new(PixelFormat::Uyvy, PixelFormat::Yuv420, resolution)
|
||||||
src_format: PixelFormat::Uyvy,
|
|
||||||
dst_format: PixelFormat::Yuv420,
|
|
||||||
resolution,
|
|
||||||
output_buffer: Yuv420pBuffer::new(resolution),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new converter for YVYU → YUV420P
|
/// Create a new converter for YVYU → YUV420P
|
||||||
pub fn yvyu_to_yuv420p(resolution: Resolution) -> Self {
|
pub fn yvyu_to_yuv420p(resolution: Resolution) -> Self {
|
||||||
Self {
|
Self::new(PixelFormat::Yvyu, PixelFormat::Yuv420, resolution)
|
||||||
src_format: PixelFormat::Yvyu,
|
|
||||||
dst_format: PixelFormat::Yuv420,
|
|
||||||
resolution,
|
|
||||||
output_buffer: Yuv420pBuffer::new(resolution),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new converter for NV12 → YUV420P
|
/// Create a new converter for NV12 → YUV420P
|
||||||
pub fn nv12_to_yuv420p(resolution: Resolution) -> Self {
|
pub fn nv12_to_yuv420p(resolution: Resolution) -> Self {
|
||||||
Self {
|
Self::new(PixelFormat::Nv12, PixelFormat::Yuv420, resolution)
|
||||||
src_format: PixelFormat::Nv12,
|
|
||||||
dst_format: PixelFormat::Yuv420,
|
|
||||||
resolution,
|
|
||||||
output_buffer: Yuv420pBuffer::new(resolution),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new converter for NV21 → YUV420P
|
/// Create a new converter for NV21 → YUV420P
|
||||||
pub fn nv21_to_yuv420p(resolution: Resolution) -> Self {
|
pub fn nv21_to_yuv420p(resolution: Resolution) -> Self {
|
||||||
Self {
|
Self::new(PixelFormat::Nv21, PixelFormat::Yuv420, resolution)
|
||||||
src_format: PixelFormat::Nv21,
|
|
||||||
dst_format: PixelFormat::Yuv420,
|
|
||||||
resolution,
|
|
||||||
output_buffer: Yuv420pBuffer::new(resolution),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a new converter for NV16 → YUV420P
|
||||||
|
pub fn nv16_to_yuv420p(resolution: Resolution) -> Self {
|
||||||
|
Self::new(PixelFormat::Nv16, PixelFormat::Yuv420, resolution)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new converter for NV24 → YUV420P
|
||||||
|
pub fn nv24_to_yuv420p(resolution: Resolution) -> Self {
|
||||||
|
Self::new(PixelFormat::Nv24, PixelFormat::Yuv420, resolution)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new converter for YVU420 → YUV420P (swap U and V planes)
|
/// Create a new converter for YVU420 → YUV420P (swap U and V planes)
|
||||||
pub fn yvu420_to_yuv420p(resolution: Resolution) -> Self {
|
pub fn yvu420_to_yuv420p(resolution: Resolution) -> Self {
|
||||||
Self {
|
Self::new(PixelFormat::Yvu420, PixelFormat::Yuv420, resolution)
|
||||||
src_format: PixelFormat::Yvu420,
|
|
||||||
dst_format: PixelFormat::Yuv420,
|
|
||||||
resolution,
|
|
||||||
output_buffer: Yuv420pBuffer::new(resolution),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new converter for RGB24 → YUV420P
|
/// Create a new converter for RGB24 → YUV420P
|
||||||
pub fn rgb24_to_yuv420p(resolution: Resolution) -> Self {
|
pub fn rgb24_to_yuv420p(resolution: Resolution) -> Self {
|
||||||
Self {
|
Self::new(PixelFormat::Rgb24, PixelFormat::Yuv420, resolution)
|
||||||
src_format: PixelFormat::Rgb24,
|
|
||||||
dst_format: PixelFormat::Yuv420,
|
|
||||||
resolution,
|
|
||||||
output_buffer: Yuv420pBuffer::new(resolution),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create a new converter for BGR24 → YUV420P
|
/// Create a new converter for BGR24 → YUV420P
|
||||||
pub fn bgr24_to_yuv420p(resolution: Resolution) -> Self {
|
pub fn bgr24_to_yuv420p(resolution: Resolution) -> Self {
|
||||||
Self {
|
Self::new(PixelFormat::Bgr24, PixelFormat::Yuv420, resolution)
|
||||||
src_format: PixelFormat::Bgr24,
|
|
||||||
dst_format: PixelFormat::Yuv420,
|
|
||||||
resolution,
|
|
||||||
output_buffer: Yuv420pBuffer::new(resolution),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert a frame and return reference to the output buffer
|
/// Convert a frame and return reference to the output buffer
|
||||||
@@ -304,6 +287,12 @@ impl PixelConverter {
|
|||||||
AppError::VideoError(format!("libyuv conversion failed: {}", e))
|
AppError::VideoError(format!("libyuv conversion failed: {}", e))
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
(PixelFormat::Nv16, PixelFormat::Yuv420) => {
|
||||||
|
self.convert_nv16_to_yuv420p(input)?;
|
||||||
|
}
|
||||||
|
(PixelFormat::Nv24, PixelFormat::Yuv420) => {
|
||||||
|
self.convert_nv24_to_yuv420p(input)?;
|
||||||
|
}
|
||||||
(PixelFormat::Rgb24, PixelFormat::Yuv420) => {
|
(PixelFormat::Rgb24, PixelFormat::Yuv420) => {
|
||||||
libyuv::rgb24_to_i420(input, self.output_buffer.as_bytes_mut(), width, height)
|
libyuv::rgb24_to_i420(input, self.output_buffer.as_bytes_mut(), width, height)
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
@@ -429,6 +418,102 @@ impl PixelConverter {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert NV16 (4:2:2 semiplanar) → YUV420P using libyuv split + I422 downsample
|
||||||
|
fn convert_nv16_to_yuv420p(&mut self, nv16: &[u8]) -> Result<()> {
|
||||||
|
let width = self.resolution.width as usize;
|
||||||
|
let height = self.resolution.height as usize;
|
||||||
|
let y_size = width * height;
|
||||||
|
let uv_size = y_size;
|
||||||
|
|
||||||
|
if nv16.len() < y_size + uv_size {
|
||||||
|
return Err(AppError::VideoError(format!(
|
||||||
|
"NV16 data too small: {} < {}",
|
||||||
|
nv16.len(),
|
||||||
|
y_size + uv_size
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let src_uv = &nv16[y_size..y_size + uv_size];
|
||||||
|
let chroma_plane_size = y_size / 2;
|
||||||
|
let (u_plane_422, rest) = self.uv_split_buffer.split_at_mut(chroma_plane_size);
|
||||||
|
let (v_plane_422, _) = rest.split_at_mut(chroma_plane_size);
|
||||||
|
|
||||||
|
libyuv::split_uv_plane(
|
||||||
|
src_uv,
|
||||||
|
width as i32,
|
||||||
|
u_plane_422,
|
||||||
|
(width / 2) as i32,
|
||||||
|
v_plane_422,
|
||||||
|
(width / 2) as i32,
|
||||||
|
(width / 2) as i32,
|
||||||
|
height as i32,
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::VideoError(format!("libyuv NV16 split failed: {}", e)))?;
|
||||||
|
|
||||||
|
libyuv::i422_to_i420_planar(
|
||||||
|
&nv16[..y_size],
|
||||||
|
width as i32,
|
||||||
|
u_plane_422,
|
||||||
|
(width / 2) as i32,
|
||||||
|
v_plane_422,
|
||||||
|
(width / 2) as i32,
|
||||||
|
self.output_buffer.as_bytes_mut(),
|
||||||
|
width as i32,
|
||||||
|
height as i32,
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::VideoError(format!("libyuv NV16→I420 failed: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert NV24 (4:4:4 semiplanar) → YUV420P using libyuv split + I444 downsample
|
||||||
|
fn convert_nv24_to_yuv420p(&mut self, nv24: &[u8]) -> Result<()> {
|
||||||
|
let width = self.resolution.width as usize;
|
||||||
|
let height = self.resolution.height as usize;
|
||||||
|
let y_size = width * height;
|
||||||
|
let uv_size = y_size * 2;
|
||||||
|
|
||||||
|
if nv24.len() < y_size + uv_size {
|
||||||
|
return Err(AppError::VideoError(format!(
|
||||||
|
"NV24 data too small: {} < {}",
|
||||||
|
nv24.len(),
|
||||||
|
y_size + uv_size
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let src_uv = &nv24[y_size..y_size + uv_size];
|
||||||
|
let chroma_plane_size = y_size;
|
||||||
|
let (u_plane_444, rest) = self.uv_split_buffer.split_at_mut(chroma_plane_size);
|
||||||
|
let (v_plane_444, _) = rest.split_at_mut(chroma_plane_size);
|
||||||
|
|
||||||
|
libyuv::split_uv_plane(
|
||||||
|
src_uv,
|
||||||
|
(width * 2) as i32,
|
||||||
|
u_plane_444,
|
||||||
|
width as i32,
|
||||||
|
v_plane_444,
|
||||||
|
width as i32,
|
||||||
|
width as i32,
|
||||||
|
height as i32,
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::VideoError(format!("libyuv NV24 split failed: {}", e)))?;
|
||||||
|
|
||||||
|
libyuv::i444_to_i420_planar(
|
||||||
|
&nv24[..y_size],
|
||||||
|
width as i32,
|
||||||
|
u_plane_444,
|
||||||
|
width as i32,
|
||||||
|
v_plane_444,
|
||||||
|
width as i32,
|
||||||
|
self.output_buffer.as_bytes_mut(),
|
||||||
|
width as i32,
|
||||||
|
height as i32,
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::VideoError(format!("libyuv NV24→I420 failed: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Calculate YUV420P buffer size for a given resolution
|
/// Calculate YUV420P buffer size for a given resolution
|
||||||
@@ -519,6 +604,16 @@ impl Nv12Converter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a new converter for NV24 → NV12
|
||||||
|
pub fn nv24_to_nv12(resolution: Resolution) -> Self {
|
||||||
|
Self {
|
||||||
|
src_format: PixelFormat::Nv24,
|
||||||
|
resolution,
|
||||||
|
output_buffer: Nv12Buffer::new(resolution),
|
||||||
|
i420_buffer: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Convert a frame and return reference to the output buffer
|
/// Convert a frame and return reference to the output buffer
|
||||||
pub fn convert(&mut self, input: &[u8]) -> Result<&[u8]> {
|
pub fn convert(&mut self, input: &[u8]) -> Result<&[u8]> {
|
||||||
let width = self.resolution.width as i32;
|
let width = self.resolution.width as i32;
|
||||||
@@ -553,6 +648,16 @@ impl Nv12Converter {
|
|||||||
)?;
|
)?;
|
||||||
return Ok(self.output_buffer.as_bytes());
|
return Ok(self.output_buffer.as_bytes());
|
||||||
}
|
}
|
||||||
|
PixelFormat::Nv24 => {
|
||||||
|
let dst = self.output_buffer.as_bytes_mut();
|
||||||
|
Self::convert_nv24_to_nv12_with_dims(
|
||||||
|
self.resolution.width as usize,
|
||||||
|
self.resolution.height as usize,
|
||||||
|
input,
|
||||||
|
dst,
|
||||||
|
)?;
|
||||||
|
return Ok(self.output_buffer.as_bytes());
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -635,6 +740,57 @@ impl Nv12Converter {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn convert_nv24_to_nv12_with_dims(
|
||||||
|
width: usize,
|
||||||
|
height: usize,
|
||||||
|
input: &[u8],
|
||||||
|
dst: &mut [u8],
|
||||||
|
) -> Result<()> {
|
||||||
|
let y_size = width * height;
|
||||||
|
let uv_size_nv24 = y_size * 2;
|
||||||
|
let uv_size_nv12 = y_size / 2;
|
||||||
|
|
||||||
|
if input.len() < y_size + uv_size_nv24 {
|
||||||
|
return Err(AppError::VideoError(format!(
|
||||||
|
"NV24 data too small: {} < {}",
|
||||||
|
input.len(),
|
||||||
|
y_size + uv_size_nv24
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
dst[..y_size].copy_from_slice(&input[..y_size]);
|
||||||
|
|
||||||
|
let src_uv = &input[y_size..y_size + uv_size_nv24];
|
||||||
|
let dst_uv = &mut dst[y_size..y_size + uv_size_nv12];
|
||||||
|
let dst_rows = height / 2;
|
||||||
|
|
||||||
|
for row in 0..dst_rows {
|
||||||
|
let src_row0 = &src_uv[row * 2 * width * 2..row * 2 * width * 2 + width * 2];
|
||||||
|
let src_row1 =
|
||||||
|
&src_uv[(row * 2 + 1) * width * 2..(row * 2 + 1) * width * 2 + width * 2];
|
||||||
|
let dst_row = &mut dst_uv[row * width..row * width + width];
|
||||||
|
|
||||||
|
for pair in 0..(width / 2) {
|
||||||
|
let src_idx0 = pair * 4;
|
||||||
|
let src_idx1 = src_idx0 + 2;
|
||||||
|
let dst_idx = pair * 2;
|
||||||
|
|
||||||
|
dst_row[dst_idx] = ((src_row0[src_idx0] as u32
|
||||||
|
+ src_row0[src_idx1] as u32
|
||||||
|
+ src_row1[src_idx0] as u32
|
||||||
|
+ src_row1[src_idx1] as u32)
|
||||||
|
/ 4) as u8;
|
||||||
|
dst_row[dst_idx + 1] = ((src_row0[src_idx0 + 1] as u32
|
||||||
|
+ src_row0[src_idx1 + 1] as u32
|
||||||
|
+ src_row1[src_idx0 + 1] as u32
|
||||||
|
+ src_row1[src_idx1 + 1] as u32)
|
||||||
|
/ 4) as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Get output buffer length
|
/// Get output buffer length
|
||||||
pub fn output_len(&self) -> usize {
|
pub fn output_len(&self) -> usize {
|
||||||
self.output_buffer.len()
|
self.output_buffer.len()
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
use v4l2r::bindings::{v4l2_frmivalenum, v4l2_frmsizeenum};
|
use v4l2r::bindings::{
|
||||||
|
v4l2_bt_timings, v4l2_dv_timings, v4l2_frmivalenum, v4l2_frmsizeenum, v4l2_streamparm,
|
||||||
|
V4L2_DV_BT_656_1120,
|
||||||
|
};
|
||||||
use v4l2r::ioctl::{
|
use v4l2r::ioctl::{
|
||||||
self, Capabilities, Capability as V4l2rCapability, FormatIterator, FrmIvalTypes, FrmSizeTypes,
|
self, Capabilities, Capability as V4l2rCapability, FormatIterator, FrmIvalTypes, FrmSizeTypes,
|
||||||
};
|
};
|
||||||
@@ -14,6 +17,7 @@ use v4l2r::nix::errno::Errno;
|
|||||||
use v4l2r::{Format as V4l2rFormat, QueueType};
|
use v4l2r::{Format as V4l2rFormat, QueueType};
|
||||||
|
|
||||||
use super::format::{PixelFormat, Resolution};
|
use super::format::{PixelFormat, Resolution};
|
||||||
|
use super::is_rk_hdmirx_driver;
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
|
|
||||||
const DEVICE_PROBE_TIMEOUT_MS: u64 = 400;
|
const DEVICE_PROBE_TIMEOUT_MS: u64 = 400;
|
||||||
@@ -57,11 +61,11 @@ pub struct FormatInfo {
|
|||||||
pub struct ResolutionInfo {
|
pub struct ResolutionInfo {
|
||||||
pub width: u32,
|
pub width: u32,
|
||||||
pub height: u32,
|
pub height: u32,
|
||||||
pub fps: Vec<u32>,
|
pub fps: Vec<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ResolutionInfo {
|
impl ResolutionInfo {
|
||||||
pub fn new(width: u32, height: u32, fps: Vec<u32>) -> Self {
|
pub fn new(width: u32, height: u32, fps: Vec<f64>) -> Self {
|
||||||
Self { width, height, fps }
|
Self { width, height, fps }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +147,11 @@ impl VideoDevice {
|
|||||||
read_write: flags.contains(Capabilities::READWRITE),
|
read_write: flags.contains(Capabilities::READWRITE),
|
||||||
};
|
};
|
||||||
|
|
||||||
let formats = self.enumerate_formats()?;
|
let formats = if is_rk_hdmirx_driver(&caps.driver, &caps.card) {
|
||||||
|
self.enumerate_current_format_only()?
|
||||||
|
} else {
|
||||||
|
self.enumerate_formats()?
|
||||||
|
};
|
||||||
|
|
||||||
// Determine if this is likely an HDMI capture card
|
// Determine if this is likely an HDMI capture card
|
||||||
let is_capture_card = Self::detect_capture_card(&caps.card, &caps.driver, &formats);
|
let is_capture_card = Self::detect_capture_card(&caps.card, &caps.driver, &formats);
|
||||||
@@ -176,6 +184,15 @@ impl VideoDevice {
|
|||||||
// Try to convert FourCC to our PixelFormat
|
// Try to convert FourCC to our PixelFormat
|
||||||
if let Some(format) = PixelFormat::from_v4l2r(desc.pixelformat) {
|
if let Some(format) = PixelFormat::from_v4l2r(desc.pixelformat) {
|
||||||
let resolutions = self.enumerate_resolutions(desc.pixelformat)?;
|
let resolutions = self.enumerate_resolutions(desc.pixelformat)?;
|
||||||
|
let is_current_format = self.current_active_format() == Some(format);
|
||||||
|
|
||||||
|
if resolutions.is_empty() && !is_current_format {
|
||||||
|
debug!(
|
||||||
|
"Skipping format {:?} ({}): not usable for current active mode",
|
||||||
|
desc.pixelformat, desc.description
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
formats.push(FormatInfo {
|
formats.push(FormatInfo {
|
||||||
format,
|
format,
|
||||||
@@ -196,9 +213,38 @@ impl VideoDevice {
|
|||||||
Ok(formats)
|
Ok(formats)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn enumerate_current_format_only(&self) -> Result<Vec<FormatInfo>> {
|
||||||
|
let current = self.get_format()?;
|
||||||
|
let Some(format) = PixelFormat::from_v4l2r(current.pixelformat) else {
|
||||||
|
debug!(
|
||||||
|
"Current active format {:?} is not supported by One-KVM, falling back to full enumeration",
|
||||||
|
current.pixelformat
|
||||||
|
);
|
||||||
|
return self.enumerate_formats();
|
||||||
|
};
|
||||||
|
|
||||||
|
let description = self
|
||||||
|
.format_description(current.pixelformat)
|
||||||
|
.unwrap_or_else(|| format.to_string());
|
||||||
|
|
||||||
|
let mut resolutions = self.enumerate_resolutions(current.pixelformat)?;
|
||||||
|
if resolutions.is_empty() {
|
||||||
|
if let Some(current_mode) = self.current_mode_resolution_info() {
|
||||||
|
resolutions.push(current_mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(vec![FormatInfo {
|
||||||
|
format,
|
||||||
|
resolutions,
|
||||||
|
description,
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
|
||||||
/// Enumerate resolutions for a specific format
|
/// Enumerate resolutions for a specific format
|
||||||
fn enumerate_resolutions(&self, fourcc: v4l2r::PixelFormat) -> Result<Vec<ResolutionInfo>> {
|
fn enumerate_resolutions(&self, fourcc: v4l2r::PixelFormat) -> Result<Vec<ResolutionInfo>> {
|
||||||
let mut resolutions = Vec::new();
|
let mut resolutions = Vec::new();
|
||||||
|
let mut should_fallback_to_current_mode = false;
|
||||||
|
|
||||||
let mut index = 0u32;
|
let mut index = 0u32;
|
||||||
loop {
|
loop {
|
||||||
@@ -241,7 +287,15 @@ impl VideoDevice {
|
|||||||
e,
|
e,
|
||||||
v4l2r::ioctl::FrameSizeError::IoctlError(err) if err == Errno::EINVAL
|
v4l2r::ioctl::FrameSizeError::IoctlError(err) if err == Errno::EINVAL
|
||||||
);
|
);
|
||||||
if !is_einval {
|
let is_unsupported = matches!(
|
||||||
|
e,
|
||||||
|
v4l2r::ioctl::FrameSizeError::IoctlError(err)
|
||||||
|
if matches!(err, Errno::ENOTTY | Errno::ENOSYS | Errno::EOPNOTSUPP)
|
||||||
|
);
|
||||||
|
if is_unsupported && resolutions.is_empty() {
|
||||||
|
should_fallback_to_current_mode = true;
|
||||||
|
}
|
||||||
|
if !is_einval && !is_unsupported {
|
||||||
debug!("Failed to enumerate frame sizes for {:?}: {}", fourcc, e);
|
debug!("Failed to enumerate frame sizes for {:?}: {}", fourcc, e);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -249,6 +303,23 @@ impl VideoDevice {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if should_fallback_to_current_mode {
|
||||||
|
if let Some(resolution) = self.current_mode_resolution_info() {
|
||||||
|
if self.format_works_for_resolution(fourcc, resolution.width, resolution.height) {
|
||||||
|
debug!(
|
||||||
|
"Falling back to current active mode for {:?}: {}x{} @ {:?} fps",
|
||||||
|
fourcc, resolution.width, resolution.height, resolution.fps
|
||||||
|
);
|
||||||
|
resolutions.push(resolution);
|
||||||
|
} else {
|
||||||
|
debug!(
|
||||||
|
"Skipping current-mode fallback for {:?}: TRY_FMT rejected {}x{}",
|
||||||
|
fourcc, resolution.width, resolution.height
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Sort by resolution (largest first)
|
// Sort by resolution (largest first)
|
||||||
resolutions.sort_by(|a, b| (b.width * b.height).cmp(&(a.width * a.height)));
|
resolutions.sort_by(|a, b| (b.width * b.height).cmp(&(a.width * a.height)));
|
||||||
resolutions.dedup_by(|a, b| a.width == b.width && a.height == b.height);
|
resolutions.dedup_by(|a, b| a.width == b.width && a.height == b.height);
|
||||||
@@ -262,8 +333,9 @@ impl VideoDevice {
|
|||||||
fourcc: v4l2r::PixelFormat,
|
fourcc: v4l2r::PixelFormat,
|
||||||
width: u32,
|
width: u32,
|
||||||
height: u32,
|
height: u32,
|
||||||
) -> Result<Vec<u32>> {
|
) -> Result<Vec<f64>> {
|
||||||
let mut fps_list = Vec::new();
|
let mut fps_list = Vec::new();
|
||||||
|
let mut should_fallback_to_current_mode = false;
|
||||||
|
|
||||||
let mut index = 0u32;
|
let mut index = 0u32;
|
||||||
loop {
|
loop {
|
||||||
@@ -274,15 +346,18 @@ impl VideoDevice {
|
|||||||
if let Some(interval) = interval.intervals() {
|
if let Some(interval) = interval.intervals() {
|
||||||
match interval {
|
match interval {
|
||||||
FrmIvalTypes::Discrete(fraction) => {
|
FrmIvalTypes::Discrete(fraction) => {
|
||||||
if fraction.numerator > 0 {
|
if fraction.numerator > 0 && fraction.denominator > 0 {
|
||||||
let fps = fraction.denominator / fraction.numerator;
|
let fps =
|
||||||
|
fraction.denominator as f64 / fraction.numerator as f64;
|
||||||
fps_list.push(fps);
|
fps_list.push(fps);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
FrmIvalTypes::StepWise(step) => {
|
FrmIvalTypes::StepWise(step) => {
|
||||||
if step.max.numerator > 0 {
|
if step.max.numerator > 0 && step.max.denominator > 0 {
|
||||||
let min_fps = step.max.denominator / step.max.numerator;
|
let min_fps =
|
||||||
let max_fps = step.min.denominator / step.min.numerator;
|
step.max.denominator as f64 / step.max.numerator as f64;
|
||||||
|
let max_fps =
|
||||||
|
step.min.denominator as f64 / step.min.numerator as f64;
|
||||||
fps_list.push(min_fps);
|
fps_list.push(min_fps);
|
||||||
if max_fps != min_fps {
|
if max_fps != min_fps {
|
||||||
fps_list.push(max_fps);
|
fps_list.push(max_fps);
|
||||||
@@ -298,7 +373,15 @@ impl VideoDevice {
|
|||||||
e,
|
e,
|
||||||
v4l2r::ioctl::FrameIntervalsError::IoctlError(err) if err == Errno::EINVAL
|
v4l2r::ioctl::FrameIntervalsError::IoctlError(err) if err == Errno::EINVAL
|
||||||
);
|
);
|
||||||
if !is_einval {
|
let is_unsupported = matches!(
|
||||||
|
e,
|
||||||
|
v4l2r::ioctl::FrameIntervalsError::IoctlError(err)
|
||||||
|
if matches!(err, Errno::ENOTTY | Errno::ENOSYS | Errno::EOPNOTSUPP)
|
||||||
|
);
|
||||||
|
if is_unsupported && fps_list.is_empty() {
|
||||||
|
should_fallback_to_current_mode = true;
|
||||||
|
}
|
||||||
|
if !is_einval && !is_unsupported {
|
||||||
debug!(
|
debug!(
|
||||||
"Failed to enumerate frame intervals for {:?} {}x{}: {}",
|
"Failed to enumerate frame intervals for {:?} {}x{}: {}",
|
||||||
fourcc, width, height, e
|
fourcc, width, height, e
|
||||||
@@ -309,8 +392,11 @@ impl VideoDevice {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fps_list.sort_by(|a, b| b.cmp(a));
|
if should_fallback_to_current_mode {
|
||||||
fps_list.dedup();
|
fps_list.extend(self.current_mode_fps());
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize_fps_list(&mut fps_list);
|
||||||
Ok(fps_list)
|
Ok(fps_list)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,6 +512,115 @@ impl VideoDevice {
|
|||||||
&self.fd
|
&self.fd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn current_mode_resolution_info(&self) -> Option<ResolutionInfo> {
|
||||||
|
let (width, height) = self
|
||||||
|
.current_dv_timings_mode()
|
||||||
|
.map(|(width, height, _)| (width, height))
|
||||||
|
.or_else(|| self.current_format_resolution())?;
|
||||||
|
Some(ResolutionInfo::new(width, height, self.current_mode_fps()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_mode_fps(&self) -> Vec<f64> {
|
||||||
|
let mut fps = Vec::new();
|
||||||
|
|
||||||
|
if let Some(frame_rate) = self.current_parm_fps() {
|
||||||
|
fps.push(frame_rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((_, _, Some(frame_rate))) = self.current_dv_timings_mode() {
|
||||||
|
fps.push(frame_rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize_fps_list(&mut fps);
|
||||||
|
fps
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_parm_fps(&self) -> Option<f64> {
|
||||||
|
let queue = self.capture_queue_type().ok()?;
|
||||||
|
let params: v4l2_streamparm = ioctl::g_parm(&self.fd, queue).ok()?;
|
||||||
|
let capture = unsafe { params.parm.capture };
|
||||||
|
let timeperframe = capture.timeperframe;
|
||||||
|
if timeperframe.numerator == 0 || timeperframe.denominator == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(timeperframe.denominator as f64 / timeperframe.numerator as f64)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_dv_timings_mode(&self) -> Option<(u32, u32, Option<f64>)> {
|
||||||
|
let timings = ioctl::query_dv_timings::<v4l2_dv_timings>(&self.fd)
|
||||||
|
.or_else(|_| ioctl::g_dv_timings::<v4l2_dv_timings>(&self.fd))
|
||||||
|
.ok()?;
|
||||||
|
|
||||||
|
if timings.type_ != V4L2_DV_BT_656_1120 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bt = unsafe { timings.__bindgen_anon_1.bt };
|
||||||
|
if bt.width == 0 || bt.height == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some((bt.width, bt.height, dv_timings_fps(&bt)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query current DV timings resolution for runtime change detection.
|
||||||
|
///
|
||||||
|
/// Returns the active resolution reported by DV timings (used by CSI/HDMI bridges
|
||||||
|
/// such as TC358743, rk_hdmirx, etc.). Returns `None` when the device does not
|
||||||
|
/// support DV timings or no signal is detected.
|
||||||
|
pub fn query_dv_timings_resolution(&self) -> Option<Resolution> {
|
||||||
|
let (w, h, _fps) = self.current_dv_timings_mode()?;
|
||||||
|
Some(Resolution::new(w, h))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_format_resolution(&self) -> Option<(u32, u32)> {
|
||||||
|
let format = self.get_format().ok()?;
|
||||||
|
if format.width == 0 || format.height == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((format.width, format.height))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_active_format(&self) -> Option<PixelFormat> {
|
||||||
|
let format = self.get_format().ok()?;
|
||||||
|
PixelFormat::from_v4l2r(format.pixelformat)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_description(&self, fourcc: v4l2r::PixelFormat) -> Option<String> {
|
||||||
|
let queue = self.capture_queue_type().ok()?;
|
||||||
|
FormatIterator::new(&self.fd, queue)
|
||||||
|
.find(|desc| desc.pixelformat == fourcc)
|
||||||
|
.map(|desc| desc.description)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_works_for_resolution(
|
||||||
|
&self,
|
||||||
|
fourcc: v4l2r::PixelFormat,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
) -> bool {
|
||||||
|
let queue = match self.capture_queue_type() {
|
||||||
|
Ok(queue) => queue,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut fmt = match ioctl::g_fmt::<V4l2rFormat>(&self.fd, queue) {
|
||||||
|
Ok(fmt) => fmt,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
fmt.width = width;
|
||||||
|
fmt.height = height;
|
||||||
|
fmt.pixelformat = fourcc;
|
||||||
|
|
||||||
|
let actual = match ioctl::try_fmt::<_, V4l2rFormat>(&self.fd, (queue, &fmt)) {
|
||||||
|
Ok(actual) => actual,
|
||||||
|
Err(_) => return false,
|
||||||
|
};
|
||||||
|
|
||||||
|
actual.pixelformat == fourcc && actual.width == width && actual.height == height
|
||||||
|
}
|
||||||
|
|
||||||
fn capture_queue_type(&self) -> Result<QueueType> {
|
fn capture_queue_type(&self) -> Result<QueueType> {
|
||||||
let caps = self.capabilities()?;
|
let caps = self.capabilities()?;
|
||||||
if caps.video_capture {
|
if caps.video_capture {
|
||||||
@@ -588,6 +783,36 @@ fn extract_uevent_value(content: &str, key: &str) -> Option<String> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn dv_timings_fps(bt: &v4l2_bt_timings) -> Option<f64> {
|
||||||
|
let total_width = bt.width + bt.hfrontporch + bt.hsync + bt.hbackporch;
|
||||||
|
let total_height = if bt.interlaced != 0 {
|
||||||
|
bt.height
|
||||||
|
+ bt.vfrontporch
|
||||||
|
+ bt.vsync
|
||||||
|
+ bt.vbackporch
|
||||||
|
+ bt.il_vfrontporch
|
||||||
|
+ bt.il_vsync
|
||||||
|
+ bt.il_vbackporch
|
||||||
|
} else {
|
||||||
|
bt.height + bt.vfrontporch + bt.vsync + bt.vbackporch
|
||||||
|
};
|
||||||
|
|
||||||
|
if bt.pixelclock == 0 || total_width == 0 || total_height == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(bt.pixelclock as f64 / total_width as f64 / total_height as f64)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_fps_list(fps_list: &mut Vec<f64>) {
|
||||||
|
fps_list.retain(|fps| fps.is_finite() && *fps > 0.0);
|
||||||
|
for fps in fps_list.iter_mut() {
|
||||||
|
*fps = (*fps * 100.0).round() / 100.0;
|
||||||
|
}
|
||||||
|
fps_list.sort_by(|a, b| b.total_cmp(a));
|
||||||
|
fps_list.dedup_by(|a, b| (*a - *b).abs() < 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
/// Find the best video device for KVM use
|
/// Find the best video device for KVM use
|
||||||
pub fn find_best_device() -> Result<VideoDeviceInfo> {
|
pub fn find_best_device() -> Result<VideoDeviceInfo> {
|
||||||
let devices = enumerate_devices()?;
|
let devices = enumerate_devices()?;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ use std::sync::Once;
|
|||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
use hwcodec::common::{Quality, RateControl};
|
use hwcodec::common::{Quality, RateControl};
|
||||||
use hwcodec::ffmpeg::AVPixelFormat;
|
use hwcodec::ffmpeg::{resolve_pixel_format, AVPixelFormat};
|
||||||
use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder};
|
use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder};
|
||||||
use hwcodec::ffmpeg_ram::CodecInfo;
|
use hwcodec::ffmpeg_ram::CodecInfo;
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@ pub fn get_available_encoders(width: u32, height: u32) -> Vec<CodecInfo> {
|
|||||||
mc_name: None,
|
mc_name: None,
|
||||||
width: width as i32,
|
width: width as i32,
|
||||||
height: height as i32,
|
height: height as i32,
|
||||||
pixfmt: AVPixelFormat::AV_PIX_FMT_YUV420P,
|
pixfmt: resolve_pixel_format("yuv420p", AVPixelFormat::AV_PIX_FMT_YUV420P),
|
||||||
align: 1,
|
align: 1,
|
||||||
fps: 30,
|
fps: 30,
|
||||||
gop: 30,
|
gop: 30,
|
||||||
@@ -273,16 +273,17 @@ impl H264Encoder {
|
|||||||
let height = config.base.resolution.height;
|
let height = config.base.resolution.height;
|
||||||
|
|
||||||
// Select pixel format based on config
|
// Select pixel format based on config
|
||||||
let pixfmt = match config.input_format {
|
let (pixfmt_name, pixfmt_fallback) = match config.input_format {
|
||||||
H264InputFormat::Nv12 => AVPixelFormat::AV_PIX_FMT_NV12,
|
H264InputFormat::Nv12 => ("nv12", AVPixelFormat::AV_PIX_FMT_NV12),
|
||||||
H264InputFormat::Nv21 => AVPixelFormat::AV_PIX_FMT_NV21,
|
H264InputFormat::Nv21 => ("nv21", AVPixelFormat::AV_PIX_FMT_NV21),
|
||||||
H264InputFormat::Nv16 => AVPixelFormat::AV_PIX_FMT_NV16,
|
H264InputFormat::Nv16 => ("nv16", AVPixelFormat::AV_PIX_FMT_NV16),
|
||||||
H264InputFormat::Nv24 => AVPixelFormat::AV_PIX_FMT_NV24,
|
H264InputFormat::Nv24 => ("nv24", AVPixelFormat::AV_PIX_FMT_NV24),
|
||||||
H264InputFormat::Yuv420p => AVPixelFormat::AV_PIX_FMT_YUV420P,
|
H264InputFormat::Yuv420p => ("yuv420p", AVPixelFormat::AV_PIX_FMT_YUV420P),
|
||||||
H264InputFormat::Yuyv422 => AVPixelFormat::AV_PIX_FMT_YUYV422,
|
H264InputFormat::Yuyv422 => ("yuyv422", AVPixelFormat::AV_PIX_FMT_YUYV422),
|
||||||
H264InputFormat::Rgb24 => AVPixelFormat::AV_PIX_FMT_RGB24,
|
H264InputFormat::Rgb24 => ("rgb24", AVPixelFormat::AV_PIX_FMT_RGB24),
|
||||||
H264InputFormat::Bgr24 => AVPixelFormat::AV_PIX_FMT_BGR24,
|
H264InputFormat::Bgr24 => ("bgr24", AVPixelFormat::AV_PIX_FMT_BGR24),
|
||||||
};
|
};
|
||||||
|
let pixfmt = resolve_pixel_format(pixfmt_name, pixfmt_fallback);
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"Creating H.264 encoder: {} at {}x{} @ {} kbps (input: {:?})",
|
"Creating H.264 encoder: {} at {}x{} @ {} kbps (input: {:?})",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use std::sync::Once;
|
|||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
use hwcodec::common::{DataFormat, Quality, RateControl};
|
use hwcodec::common::{DataFormat, Quality, RateControl};
|
||||||
use hwcodec::ffmpeg::AVPixelFormat;
|
use hwcodec::ffmpeg::{resolve_pixel_format, AVPixelFormat};
|
||||||
use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder};
|
use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder};
|
||||||
use hwcodec::ffmpeg_ram::CodecInfo;
|
use hwcodec::ffmpeg_ram::CodecInfo;
|
||||||
|
|
||||||
@@ -198,7 +198,7 @@ pub fn get_available_h265_encoders(width: u32, height: u32) -> Vec<CodecInfo> {
|
|||||||
mc_name: None,
|
mc_name: None,
|
||||||
width: width as i32,
|
width: width as i32,
|
||||||
height: height as i32,
|
height: height as i32,
|
||||||
pixfmt: AVPixelFormat::AV_PIX_FMT_NV12,
|
pixfmt: resolve_pixel_format("nv12", AVPixelFormat::AV_PIX_FMT_NV12),
|
||||||
align: 1,
|
align: 1,
|
||||||
fps: 30,
|
fps: 30,
|
||||||
gop: 30,
|
gop: 30,
|
||||||
@@ -310,24 +310,57 @@ impl H265Encoder {
|
|||||||
let height = config.base.resolution.height;
|
let height = config.base.resolution.height;
|
||||||
|
|
||||||
// Software encoders (libx265) require YUV420P, hardware encoders use NV12 or YUYV422
|
// Software encoders (libx265) require YUV420P, hardware encoders use NV12 or YUYV422
|
||||||
let (pixfmt, actual_input_format) = if is_software {
|
let (pixfmt_name, pixfmt_fallback, actual_input_format) = if is_software {
|
||||||
(AVPixelFormat::AV_PIX_FMT_YUV420P, H265InputFormat::Yuv420p)
|
(
|
||||||
|
"yuv420p",
|
||||||
|
AVPixelFormat::AV_PIX_FMT_YUV420P,
|
||||||
|
H265InputFormat::Yuv420p,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
match config.input_format {
|
match config.input_format {
|
||||||
H265InputFormat::Nv12 => (AVPixelFormat::AV_PIX_FMT_NV12, H265InputFormat::Nv12),
|
H265InputFormat::Nv12 => (
|
||||||
H265InputFormat::Nv21 => (AVPixelFormat::AV_PIX_FMT_NV21, H265InputFormat::Nv21),
|
"nv12",
|
||||||
H265InputFormat::Nv16 => (AVPixelFormat::AV_PIX_FMT_NV16, H265InputFormat::Nv16),
|
AVPixelFormat::AV_PIX_FMT_NV12,
|
||||||
H265InputFormat::Nv24 => (AVPixelFormat::AV_PIX_FMT_NV24, H265InputFormat::Nv24),
|
H265InputFormat::Nv12,
|
||||||
H265InputFormat::Yuv420p => {
|
),
|
||||||
(AVPixelFormat::AV_PIX_FMT_YUV420P, H265InputFormat::Yuv420p)
|
H265InputFormat::Nv21 => (
|
||||||
}
|
"nv21",
|
||||||
H265InputFormat::Yuyv422 => {
|
AVPixelFormat::AV_PIX_FMT_NV21,
|
||||||
(AVPixelFormat::AV_PIX_FMT_YUYV422, H265InputFormat::Yuyv422)
|
H265InputFormat::Nv21,
|
||||||
}
|
),
|
||||||
H265InputFormat::Rgb24 => (AVPixelFormat::AV_PIX_FMT_RGB24, H265InputFormat::Rgb24),
|
H265InputFormat::Nv16 => (
|
||||||
H265InputFormat::Bgr24 => (AVPixelFormat::AV_PIX_FMT_BGR24, H265InputFormat::Bgr24),
|
"nv16",
|
||||||
|
AVPixelFormat::AV_PIX_FMT_NV16,
|
||||||
|
H265InputFormat::Nv16,
|
||||||
|
),
|
||||||
|
H265InputFormat::Nv24 => (
|
||||||
|
"nv24",
|
||||||
|
AVPixelFormat::AV_PIX_FMT_NV24,
|
||||||
|
H265InputFormat::Nv24,
|
||||||
|
),
|
||||||
|
H265InputFormat::Yuv420p => (
|
||||||
|
"yuv420p",
|
||||||
|
AVPixelFormat::AV_PIX_FMT_YUV420P,
|
||||||
|
H265InputFormat::Yuv420p,
|
||||||
|
),
|
||||||
|
H265InputFormat::Yuyv422 => (
|
||||||
|
"yuyv422",
|
||||||
|
AVPixelFormat::AV_PIX_FMT_YUYV422,
|
||||||
|
H265InputFormat::Yuyv422,
|
||||||
|
),
|
||||||
|
H265InputFormat::Rgb24 => (
|
||||||
|
"rgb24",
|
||||||
|
AVPixelFormat::AV_PIX_FMT_RGB24,
|
||||||
|
H265InputFormat::Rgb24,
|
||||||
|
),
|
||||||
|
H265InputFormat::Bgr24 => (
|
||||||
|
"bgr24",
|
||||||
|
AVPixelFormat::AV_PIX_FMT_BGR24,
|
||||||
|
H265InputFormat::Bgr24,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let pixfmt = resolve_pixel_format(pixfmt_name, pixfmt_fallback);
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"Creating H.265 encoder: {} at {}x{} @ {} kbps (input: {:?})",
|
"Creating H.265 encoder: {} at {}x{} @ {} kbps (input: {:?})",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
//! JPEG encoder implementation
|
//! JPEG encoder implementation
|
||||||
//!
|
//!
|
||||||
//! Provides JPEG encoding for raw video frames (YUYV, NV12, RGB, BGR)
|
//! Provides JPEG encoding for raw video frames (YUYV, NV12, NV16, NV24, RGB, BGR)
|
||||||
//! Uses libyuv for SIMD-accelerated color space conversion to I420,
|
//! Uses libyuv for SIMD-accelerated color space conversion to I420,
|
||||||
//! then turbojpeg for direct YUV encoding (skips internal color conversion).
|
//! then turbojpeg for direct YUV encoding (skips internal color conversion).
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ use crate::video::format::{PixelFormat, Resolution};
|
|||||||
///
|
///
|
||||||
/// Encoding pipeline (all SIMD accelerated):
|
/// Encoding pipeline (all SIMD accelerated):
|
||||||
/// ```text
|
/// ```text
|
||||||
/// YUYV/NV12/BGR24/RGB24 ──libyuv──> I420 ──turbojpeg──> JPEG
|
/// YUYV/NV12/NV16/NV24/BGR24/RGB24 ──libyuv──> I420 ──turbojpeg──> JPEG
|
||||||
/// ```
|
/// ```
|
||||||
///
|
///
|
||||||
/// Note: This encoder is NOT thread-safe due to turbojpeg limitations.
|
/// Note: This encoder is NOT thread-safe due to turbojpeg limitations.
|
||||||
@@ -24,6 +24,10 @@ pub struct JpegEncoder {
|
|||||||
compressor: turbojpeg::Compressor,
|
compressor: turbojpeg::Compressor,
|
||||||
/// I420 buffer for YUV encoding (Y + U + V planes)
|
/// I420 buffer for YUV encoding (Y + U + V planes)
|
||||||
i420_buffer: Vec<u8>,
|
i420_buffer: Vec<u8>,
|
||||||
|
/// Scratch buffer for split chroma planes when converting semiplanar 4:2:2 / 4:4:4 input.
|
||||||
|
uv_split_buffer: Vec<u8>,
|
||||||
|
/// BGRA buffer used when a source format needs explicit YUV matrix expansion before JPEG.
|
||||||
|
bgra_buffer: Vec<u8>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl JpegEncoder {
|
impl JpegEncoder {
|
||||||
@@ -34,6 +38,8 @@ impl JpegEncoder {
|
|||||||
let height = resolution.height as usize;
|
let height = resolution.height as usize;
|
||||||
// I420: Y = width*height, U = width*height/4, V = width*height/4
|
// I420: Y = width*height, U = width*height/4, V = width*height/4
|
||||||
let i420_size = width * height * 3 / 2;
|
let i420_size = width * height * 3 / 2;
|
||||||
|
let max_uv_plane_size = width * height;
|
||||||
|
let bgra_size = width * height * 4;
|
||||||
|
|
||||||
let mut compressor = turbojpeg::Compressor::new().map_err(|e| {
|
let mut compressor = turbojpeg::Compressor::new().map_err(|e| {
|
||||||
AppError::VideoError(format!("Failed to create turbojpeg compressor: {}", e))
|
AppError::VideoError(format!("Failed to create turbojpeg compressor: {}", e))
|
||||||
@@ -47,6 +53,8 @@ impl JpegEncoder {
|
|||||||
config,
|
config,
|
||||||
compressor,
|
compressor,
|
||||||
i420_buffer: vec![0u8; i420_size],
|
i420_buffer: vec![0u8; i420_size],
|
||||||
|
uv_split_buffer: vec![0u8; max_uv_plane_size * 2],
|
||||||
|
bgra_buffer: vec![0u8; bgra_size],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +101,36 @@ impl JpegEncoder {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Encode BGRA buffer to JPEG using turbojpeg's RGB path.
|
||||||
|
#[inline]
|
||||||
|
fn encode_bgra_to_jpeg(&mut self, sequence: u64) -> Result<EncodedFrame> {
|
||||||
|
let width = self.config.resolution.width as usize;
|
||||||
|
let height = self.config.resolution.height as usize;
|
||||||
|
|
||||||
|
self.compressor
|
||||||
|
.set_subsamp(turbojpeg::Subsamp::Sub2x2)
|
||||||
|
.map_err(|e| AppError::VideoError(format!("Failed to set JPEG subsampling: {}", e)))?;
|
||||||
|
|
||||||
|
let image = turbojpeg::Image {
|
||||||
|
pixels: self.bgra_buffer.as_slice(),
|
||||||
|
width,
|
||||||
|
pitch: width * 4,
|
||||||
|
height,
|
||||||
|
format: turbojpeg::PixelFormat::BGRA,
|
||||||
|
};
|
||||||
|
|
||||||
|
let jpeg_data = self
|
||||||
|
.compressor
|
||||||
|
.compress_to_vec(image)
|
||||||
|
.map_err(|e| AppError::VideoError(format!("JPEG compression failed: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(EncodedFrame::jpeg(
|
||||||
|
Bytes::from(jpeg_data),
|
||||||
|
self.config.resolution,
|
||||||
|
sequence,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
/// Encode YUYV (YUV422) frame to JPEG
|
/// Encode YUYV (YUV422) frame to JPEG
|
||||||
pub fn encode_yuyv(&mut self, data: &[u8], sequence: u64) -> Result<EncodedFrame> {
|
pub fn encode_yuyv(&mut self, data: &[u8], sequence: u64) -> Result<EncodedFrame> {
|
||||||
let width = self.config.resolution.width as usize;
|
let width = self.config.resolution.width as usize;
|
||||||
@@ -135,6 +173,101 @@ impl JpegEncoder {
|
|||||||
self.encode_i420_to_jpeg(sequence)
|
self.encode_i420_to_jpeg(sequence)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Encode NV16 frame to JPEG
|
||||||
|
pub fn encode_nv16(&mut self, data: &[u8], sequence: u64) -> Result<EncodedFrame> {
|
||||||
|
let width = self.config.resolution.width as usize;
|
||||||
|
let height = self.config.resolution.height as usize;
|
||||||
|
let y_size = width * height;
|
||||||
|
let uv_size = y_size;
|
||||||
|
let expected_size = y_size + uv_size;
|
||||||
|
|
||||||
|
if data.len() < expected_size {
|
||||||
|
return Err(AppError::VideoError(format!(
|
||||||
|
"NV16 data too small: {} < {}",
|
||||||
|
data.len(),
|
||||||
|
expected_size
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let src_uv = &data[y_size..expected_size];
|
||||||
|
let chroma_plane_size = y_size / 2;
|
||||||
|
let (u_plane_422, rest) = self.uv_split_buffer.split_at_mut(chroma_plane_size);
|
||||||
|
let (v_plane_422, _) = rest.split_at_mut(chroma_plane_size);
|
||||||
|
|
||||||
|
libyuv::split_uv_plane(
|
||||||
|
src_uv,
|
||||||
|
width as i32,
|
||||||
|
u_plane_422,
|
||||||
|
(width / 2) as i32,
|
||||||
|
v_plane_422,
|
||||||
|
(width / 2) as i32,
|
||||||
|
(width / 2) as i32,
|
||||||
|
height as i32,
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::VideoError(format!("libyuv NV16 split failed: {}", e)))?;
|
||||||
|
|
||||||
|
libyuv::i422_to_i420_planar(
|
||||||
|
&data[..y_size],
|
||||||
|
width as i32,
|
||||||
|
u_plane_422,
|
||||||
|
(width / 2) as i32,
|
||||||
|
v_plane_422,
|
||||||
|
(width / 2) as i32,
|
||||||
|
&mut self.i420_buffer,
|
||||||
|
width as i32,
|
||||||
|
height as i32,
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::VideoError(format!("libyuv NV16→I420 failed: {}", e)))?;
|
||||||
|
|
||||||
|
self.encode_i420_to_jpeg(sequence)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encode NV24 frame to JPEG
|
||||||
|
pub fn encode_nv24(&mut self, data: &[u8], sequence: u64) -> Result<EncodedFrame> {
|
||||||
|
let width = self.config.resolution.width as usize;
|
||||||
|
let height = self.config.resolution.height as usize;
|
||||||
|
let y_size = width * height;
|
||||||
|
let uv_size = y_size * 2;
|
||||||
|
let expected_size = y_size + uv_size;
|
||||||
|
|
||||||
|
if data.len() < expected_size {
|
||||||
|
return Err(AppError::VideoError(format!(
|
||||||
|
"NV24 data too small: {} < {}",
|
||||||
|
data.len(),
|
||||||
|
expected_size
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let src_uv = &data[y_size..expected_size];
|
||||||
|
let chroma_plane_size = y_size;
|
||||||
|
let (u_plane_444, rest) = self.uv_split_buffer.split_at_mut(chroma_plane_size);
|
||||||
|
let (v_plane_444, _) = rest.split_at_mut(chroma_plane_size);
|
||||||
|
|
||||||
|
libyuv::split_uv_plane(
|
||||||
|
src_uv,
|
||||||
|
(width * 2) as i32,
|
||||||
|
u_plane_444,
|
||||||
|
width as i32,
|
||||||
|
v_plane_444,
|
||||||
|
width as i32,
|
||||||
|
width as i32,
|
||||||
|
height as i32,
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::VideoError(format!("libyuv NV24 split failed: {}", e)))?;
|
||||||
|
|
||||||
|
libyuv::h444_to_bgra(
|
||||||
|
&data[..y_size],
|
||||||
|
u_plane_444,
|
||||||
|
v_plane_444,
|
||||||
|
&mut self.bgra_buffer,
|
||||||
|
width as i32,
|
||||||
|
height as i32,
|
||||||
|
)
|
||||||
|
.map_err(|e| AppError::VideoError(format!("libyuv NV24(H444)→BGRA failed: {}", e)))?;
|
||||||
|
|
||||||
|
self.encode_bgra_to_jpeg(sequence)
|
||||||
|
}
|
||||||
|
|
||||||
/// Encode RGB24 frame to JPEG
|
/// Encode RGB24 frame to JPEG
|
||||||
pub fn encode_rgb(&mut self, data: &[u8], sequence: u64) -> Result<EncodedFrame> {
|
pub fn encode_rgb(&mut self, data: &[u8], sequence: u64) -> Result<EncodedFrame> {
|
||||||
let width = self.config.resolution.width as usize;
|
let width = self.config.resolution.width as usize;
|
||||||
@@ -192,6 +325,8 @@ impl crate::video::encoder::traits::Encoder for JpegEncoder {
|
|||||||
match self.config.input_format {
|
match self.config.input_format {
|
||||||
PixelFormat::Yuyv | PixelFormat::Yvyu => self.encode_yuyv(data, sequence),
|
PixelFormat::Yuyv | PixelFormat::Yvyu => self.encode_yuyv(data, sequence),
|
||||||
PixelFormat::Nv12 => self.encode_nv12(data, sequence),
|
PixelFormat::Nv12 => self.encode_nv12(data, sequence),
|
||||||
|
PixelFormat::Nv16 => self.encode_nv16(data, sequence),
|
||||||
|
PixelFormat::Nv24 => self.encode_nv24(data, sequence),
|
||||||
PixelFormat::Rgb24 => self.encode_rgb(data, sequence),
|
PixelFormat::Rgb24 => self.encode_rgb(data, sequence),
|
||||||
PixelFormat::Bgr24 => self.encode_bgr(data, sequence),
|
PixelFormat::Bgr24 => self.encode_bgr(data, sequence),
|
||||||
_ => Err(AppError::VideoError(format!(
|
_ => Err(AppError::VideoError(format!(
|
||||||
@@ -211,6 +346,8 @@ impl crate::video::encoder::traits::Encoder for JpegEncoder {
|
|||||||
PixelFormat::Yuyv
|
PixelFormat::Yuyv
|
||||||
| PixelFormat::Yvyu
|
| PixelFormat::Yvyu
|
||||||
| PixelFormat::Nv12
|
| PixelFormat::Nv12
|
||||||
|
| PixelFormat::Nv16
|
||||||
|
| PixelFormat::Nv24
|
||||||
| PixelFormat::Rgb24
|
| PixelFormat::Rgb24
|
||||||
| PixelFormat::Bgr24
|
| PixelFormat::Bgr24
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use std::time::Duration;
|
|||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use hwcodec::common::{DataFormat, Quality, RateControl};
|
use hwcodec::common::{DataFormat, Quality, RateControl};
|
||||||
use hwcodec::ffmpeg::AVPixelFormat;
|
use hwcodec::ffmpeg::{resolve_pixel_format, AVPixelFormat};
|
||||||
use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder};
|
use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder};
|
||||||
use hwcodec::ffmpeg_ram::CodecInfo;
|
use hwcodec::ffmpeg_ram::CodecInfo;
|
||||||
|
|
||||||
@@ -309,7 +309,7 @@ impl EncoderRegistry {
|
|||||||
mc_name: None,
|
mc_name: None,
|
||||||
width: width as i32,
|
width: width as i32,
|
||||||
height: height as i32,
|
height: height as i32,
|
||||||
pixfmt: AVPixelFormat::AV_PIX_FMT_NV12,
|
pixfmt: resolve_pixel_format("nv12", AVPixelFormat::AV_PIX_FMT_NV12),
|
||||||
align: 1,
|
align: 1,
|
||||||
fps: 30,
|
fps: 30,
|
||||||
gop: 30,
|
gop: 30,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use std::sync::Once;
|
|||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
use hwcodec::common::{DataFormat, Quality, RateControl};
|
use hwcodec::common::{DataFormat, Quality, RateControl};
|
||||||
use hwcodec::ffmpeg::AVPixelFormat;
|
use hwcodec::ffmpeg::{resolve_pixel_format, AVPixelFormat};
|
||||||
use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder};
|
use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder};
|
||||||
use hwcodec::ffmpeg_ram::CodecInfo;
|
use hwcodec::ffmpeg_ram::CodecInfo;
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ pub fn get_available_vp8_encoders(width: u32, height: u32) -> Vec<CodecInfo> {
|
|||||||
mc_name: None,
|
mc_name: None,
|
||||||
width: width as i32,
|
width: width as i32,
|
||||||
height: height as i32,
|
height: height as i32,
|
||||||
pixfmt: AVPixelFormat::AV_PIX_FMT_NV12,
|
pixfmt: resolve_pixel_format("nv12", AVPixelFormat::AV_PIX_FMT_NV12),
|
||||||
align: 1,
|
align: 1,
|
||||||
fps: 30,
|
fps: 30,
|
||||||
gop: 30,
|
gop: 30,
|
||||||
@@ -244,16 +244,25 @@ impl VP8Encoder {
|
|||||||
let height = config.base.resolution.height;
|
let height = config.base.resolution.height;
|
||||||
|
|
||||||
// Software encoders (libvpx) require YUV420P, hardware (VAAPI) uses NV12
|
// Software encoders (libvpx) require YUV420P, hardware (VAAPI) uses NV12
|
||||||
let (pixfmt, actual_input_format) = if is_software {
|
let (pixfmt_name, pixfmt_fallback, actual_input_format) = if is_software {
|
||||||
(AVPixelFormat::AV_PIX_FMT_YUV420P, VP8InputFormat::Yuv420p)
|
(
|
||||||
|
"yuv420p",
|
||||||
|
AVPixelFormat::AV_PIX_FMT_YUV420P,
|
||||||
|
VP8InputFormat::Yuv420p,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
match config.input_format {
|
match config.input_format {
|
||||||
VP8InputFormat::Nv12 => (AVPixelFormat::AV_PIX_FMT_NV12, VP8InputFormat::Nv12),
|
VP8InputFormat::Nv12 => {
|
||||||
VP8InputFormat::Yuv420p => {
|
("nv12", AVPixelFormat::AV_PIX_FMT_NV12, VP8InputFormat::Nv12)
|
||||||
(AVPixelFormat::AV_PIX_FMT_YUV420P, VP8InputFormat::Yuv420p)
|
|
||||||
}
|
}
|
||||||
|
VP8InputFormat::Yuv420p => (
|
||||||
|
"yuv420p",
|
||||||
|
AVPixelFormat::AV_PIX_FMT_YUV420P,
|
||||||
|
VP8InputFormat::Yuv420p,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let pixfmt = resolve_pixel_format(pixfmt_name, pixfmt_fallback);
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"Creating VP8 encoder: {} at {}x{} @ {} kbps (input: {:?})",
|
"Creating VP8 encoder: {} at {}x{} @ {} kbps (input: {:?})",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use std::sync::Once;
|
|||||||
use tracing::{debug, error, info, warn};
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
use hwcodec::common::{DataFormat, Quality, RateControl};
|
use hwcodec::common::{DataFormat, Quality, RateControl};
|
||||||
use hwcodec::ffmpeg::AVPixelFormat;
|
use hwcodec::ffmpeg::{resolve_pixel_format, AVPixelFormat};
|
||||||
use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder};
|
use hwcodec::ffmpeg_ram::encode::{EncodeContext, Encoder as HwEncoder};
|
||||||
use hwcodec::ffmpeg_ram::CodecInfo;
|
use hwcodec::ffmpeg_ram::CodecInfo;
|
||||||
|
|
||||||
@@ -133,7 +133,7 @@ pub fn get_available_vp9_encoders(width: u32, height: u32) -> Vec<CodecInfo> {
|
|||||||
mc_name: None,
|
mc_name: None,
|
||||||
width: width as i32,
|
width: width as i32,
|
||||||
height: height as i32,
|
height: height as i32,
|
||||||
pixfmt: AVPixelFormat::AV_PIX_FMT_NV12,
|
pixfmt: resolve_pixel_format("nv12", AVPixelFormat::AV_PIX_FMT_NV12),
|
||||||
align: 1,
|
align: 1,
|
||||||
fps: 30,
|
fps: 30,
|
||||||
gop: 30,
|
gop: 30,
|
||||||
@@ -244,16 +244,25 @@ impl VP9Encoder {
|
|||||||
let height = config.base.resolution.height;
|
let height = config.base.resolution.height;
|
||||||
|
|
||||||
// Software encoders (libvpx-vp9) require YUV420P, hardware (VAAPI) uses NV12
|
// Software encoders (libvpx-vp9) require YUV420P, hardware (VAAPI) uses NV12
|
||||||
let (pixfmt, actual_input_format) = if is_software {
|
let (pixfmt_name, pixfmt_fallback, actual_input_format) = if is_software {
|
||||||
(AVPixelFormat::AV_PIX_FMT_YUV420P, VP9InputFormat::Yuv420p)
|
(
|
||||||
|
"yuv420p",
|
||||||
|
AVPixelFormat::AV_PIX_FMT_YUV420P,
|
||||||
|
VP9InputFormat::Yuv420p,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
match config.input_format {
|
match config.input_format {
|
||||||
VP9InputFormat::Nv12 => (AVPixelFormat::AV_PIX_FMT_NV12, VP9InputFormat::Nv12),
|
VP9InputFormat::Nv12 => {
|
||||||
VP9InputFormat::Yuv420p => {
|
("nv12", AVPixelFormat::AV_PIX_FMT_NV12, VP9InputFormat::Nv12)
|
||||||
(AVPixelFormat::AV_PIX_FMT_YUV420P, VP9InputFormat::Yuv420p)
|
|
||||||
}
|
}
|
||||||
|
VP9InputFormat::Yuv420p => (
|
||||||
|
"yuv420p",
|
||||||
|
AVPixelFormat::AV_PIX_FMT_YUV420P,
|
||||||
|
VP9InputFormat::Yuv420p,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
let pixfmt = resolve_pixel_format(pixfmt_name, pixfmt_fallback);
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"Creating VP9 encoder: {} at {}x{} @ {} kbps (input: {:?})",
|
"Creating VP9 encoder: {} at {}x{} @ {} kbps (input: {:?})",
|
||||||
|
|||||||
@@ -24,3 +24,11 @@ pub use shared_video_pipeline::{
|
|||||||
};
|
};
|
||||||
pub use stream_manager::VideoStreamManager;
|
pub use stream_manager::VideoStreamManager;
|
||||||
pub use streamer::{Streamer, StreamerState};
|
pub use streamer::{Streamer, StreamerState};
|
||||||
|
|
||||||
|
pub(crate) fn is_rk_hdmirx_driver(driver: &str, card: &str) -> bool {
|
||||||
|
driver.eq_ignore_ascii_case("rk_hdmirx") || card.eq_ignore_ascii_case("rk_hdmirx")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_rk_hdmirx_device(device: &device::VideoDeviceInfo) -> bool {
|
||||||
|
is_rk_hdmirx_driver(&device.driver, &device.card)
|
||||||
|
}
|
||||||
|
|||||||
@@ -31,8 +31,12 @@ use self::encoder_state::{build_encoder_state, EncoderThreadState};
|
|||||||
|
|
||||||
/// Grace period before auto-stopping pipeline when no subscribers (in seconds)
|
/// Grace period before auto-stopping pipeline when no subscribers (in seconds)
|
||||||
const AUTO_STOP_GRACE_PERIOD_SECS: u64 = 3;
|
const AUTO_STOP_GRACE_PERIOD_SECS: u64 = 3;
|
||||||
/// Restart capture stream after this many consecutive timeouts.
|
/// After this many consecutive timeouts, log a prominent warning.
|
||||||
const CAPTURE_TIMEOUT_RESTART_THRESHOLD: u32 = 5;
|
const CAPTURE_TIMEOUT_RESTART_THRESHOLD: u32 = 5;
|
||||||
|
/// After this many consecutive timeouts, actually stop the pipeline.
|
||||||
|
/// Setting this high (60 × 2 s poll = ~120 s) keeps WebRTC sessions alive
|
||||||
|
/// while the source is temporarily unavailable (e.g. resolution change/reboot).
|
||||||
|
const CAPTURE_TIMEOUT_STOP_THRESHOLD: u32 = 60;
|
||||||
/// Minimum valid frame size for capture
|
/// Minimum valid frame size for capture
|
||||||
const MIN_CAPTURE_FRAME_SIZE: usize = 128;
|
const MIN_CAPTURE_FRAME_SIZE: usize = 128;
|
||||||
/// Validate every JPEG frame during startup to avoid poisoning HW decoders
|
/// Validate every JPEG frame during startup to avoid poisoning HW decoders
|
||||||
@@ -576,9 +580,16 @@ impl SharedVideoPipeline {
|
|||||||
consecutive_timeouts = consecutive_timeouts.saturating_add(1);
|
consecutive_timeouts = consecutive_timeouts.saturating_add(1);
|
||||||
warn!("Capture timeout - no signal?");
|
warn!("Capture timeout - no signal?");
|
||||||
|
|
||||||
if consecutive_timeouts >= CAPTURE_TIMEOUT_RESTART_THRESHOLD {
|
if consecutive_timeouts == CAPTURE_TIMEOUT_RESTART_THRESHOLD {
|
||||||
warn!(
|
warn!(
|
||||||
"Capture timed out {} consecutive times, restarting video pipeline",
|
"Capture timed out {} consecutive times – no signal?",
|
||||||
|
consecutive_timeouts
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if consecutive_timeouts >= CAPTURE_TIMEOUT_STOP_THRESHOLD {
|
||||||
|
warn!(
|
||||||
|
"Capture timed out {} consecutive times, stopping video pipeline",
|
||||||
consecutive_timeouts
|
consecutive_timeouts
|
||||||
);
|
);
|
||||||
let _ = pipeline.running.send(false);
|
let _ = pipeline.running.send(false);
|
||||||
@@ -833,7 +844,7 @@ impl SharedVideoPipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop the pipeline
|
/// Stop the pipeline (non-blocking, does not wait for capture thread to exit)
|
||||||
pub fn stop(&self) {
|
pub fn stop(&self) {
|
||||||
if *self.running_rx.borrow() {
|
if *self.running_rx.borrow() {
|
||||||
let _ = self.running.send(false);
|
let _ = self.running.send(false);
|
||||||
@@ -843,6 +854,39 @@ impl SharedVideoPipeline {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Stop the pipeline and wait for the capture thread to fully exit.
|
||||||
|
///
|
||||||
|
/// This ensures the V4L2 device is released before returning, which is
|
||||||
|
/// necessary when another consumer (e.g. MJPEG streamer) needs to open
|
||||||
|
/// the same device immediately after.
|
||||||
|
pub async fn stop_and_wait(&self, timeout: std::time::Duration) {
|
||||||
|
self.stop();
|
||||||
|
let mut rx = self.running_watch();
|
||||||
|
if !*rx.borrow() {
|
||||||
|
// Capture thread may still be running from a previous `stop()` call.
|
||||||
|
// Wait for the "Video pipeline stopped" log (thread sets running=false
|
||||||
|
// at exit), unless it already happened.
|
||||||
|
}
|
||||||
|
let deadline = tokio::time::Instant::now() + timeout;
|
||||||
|
loop {
|
||||||
|
if !self.running_flag.load(Ordering::Acquire) {
|
||||||
|
// Flag is cleared, but the capture thread may still be unwinding
|
||||||
|
// (dropping the V4L2 stream). Give it a brief moment.
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
|
||||||
|
if remaining.is_zero() {
|
||||||
|
warn!(
|
||||||
|
"Timed out waiting for video pipeline to stop after {:?}",
|
||||||
|
timeout
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let _ = tokio::time::timeout(remaining, rx.changed()).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Set bitrate using preset
|
/// Set bitrate using preset
|
||||||
pub async fn set_bitrate_preset(
|
pub async fn set_bitrate_preset(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -599,6 +599,14 @@ fn converters_for_pipeline(
|
|||||||
info!("Using NV21->YUV420P converter");
|
info!("Using NV21->YUV420P converter");
|
||||||
Ok((None, Some(PixelConverter::nv21_to_yuv420p(resolution))))
|
Ok((None, Some(PixelConverter::nv21_to_yuv420p(resolution))))
|
||||||
}
|
}
|
||||||
|
PixelFormat::Nv16 => {
|
||||||
|
info!("Using NV16->YUV420P converter");
|
||||||
|
Ok((None, Some(PixelConverter::nv16_to_yuv420p(resolution))))
|
||||||
|
}
|
||||||
|
PixelFormat::Nv24 => {
|
||||||
|
info!("Using NV24->YUV420P converter");
|
||||||
|
Ok((None, Some(PixelConverter::nv24_to_yuv420p(resolution))))
|
||||||
|
}
|
||||||
PixelFormat::Rgb24 => {
|
PixelFormat::Rgb24 => {
|
||||||
info!("Using RGB24->YUV420P converter");
|
info!("Using RGB24->YUV420P converter");
|
||||||
Ok((None, Some(PixelConverter::rgb24_to_yuv420p(resolution))))
|
Ok((None, Some(PixelConverter::rgb24_to_yuv420p(resolution))))
|
||||||
@@ -631,6 +639,10 @@ fn converters_for_pipeline(
|
|||||||
info!("Using NV16->NV12 converter");
|
info!("Using NV16->NV12 converter");
|
||||||
Ok((Some(Nv12Converter::nv16_to_nv12(resolution)), None))
|
Ok((Some(Nv12Converter::nv16_to_nv12(resolution)), None))
|
||||||
}
|
}
|
||||||
|
PixelFormat::Nv24 => {
|
||||||
|
info!("Using NV24->NV12 converter");
|
||||||
|
Ok((Some(Nv12Converter::nv24_to_nv12(resolution)), None))
|
||||||
|
}
|
||||||
PixelFormat::Yuv420 => {
|
PixelFormat::Yuv420 => {
|
||||||
info!("Using YUV420P->NV12 converter");
|
info!("Using YUV420P->NV12 converter");
|
||||||
Ok((Some(Nv12Converter::yuv420_to_nv12(resolution)), None))
|
Ok((Some(Nv12Converter::yuv420_to_nv12(resolution)), None))
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ use crate::hid::HidController;
|
|||||||
use crate::stream::MjpegStreamHandler;
|
use crate::stream::MjpegStreamHandler;
|
||||||
use crate::video::codec_constraints::StreamCodecConstraints;
|
use crate::video::codec_constraints::StreamCodecConstraints;
|
||||||
use crate::video::format::{PixelFormat, Resolution};
|
use crate::video::format::{PixelFormat, Resolution};
|
||||||
|
use crate::video::is_rk_hdmirx_device;
|
||||||
use crate::video::streamer::{Streamer, StreamerState};
|
use crate::video::streamer::{Streamer, StreamerState};
|
||||||
use crate::webrtc::WebRtcStreamer;
|
use crate::webrtc::WebRtcStreamer;
|
||||||
|
|
||||||
@@ -403,8 +404,11 @@ impl VideoStreamManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
StreamMode::WebRTC => {
|
StreamMode::WebRTC => {
|
||||||
info!("Closing all WebRTC sessions");
|
info!("Closing all WebRTC sessions and releasing capture device");
|
||||||
let closed = self.webrtc_streamer.close_all_sessions().await;
|
let closed = self
|
||||||
|
.webrtc_streamer
|
||||||
|
.close_all_sessions_and_release_device()
|
||||||
|
.await;
|
||||||
if closed > 0 {
|
if closed > 0 {
|
||||||
info!("Closed {} WebRTC sessions", closed);
|
info!("Closed {} WebRTC sessions", closed);
|
||||||
}
|
}
|
||||||
@@ -427,7 +431,8 @@ impl VideoStreamManager {
|
|||||||
device.formats.iter().map(|f| f.format).collect();
|
device.formats.iter().map(|f| f.format).collect();
|
||||||
|
|
||||||
// If current format is not MJPEG and device supports MJPEG, switch to it
|
// If current format is not MJPEG and device supports MJPEG, switch to it
|
||||||
if current_format != PixelFormat::Mjpeg
|
if !is_rk_hdmirx_device(&device)
|
||||||
|
&& current_format != PixelFormat::Mjpeg
|
||||||
&& available_formats.contains(&PixelFormat::Mjpeg)
|
&& available_formats.contains(&PixelFormat::Mjpeg)
|
||||||
{
|
{
|
||||||
info!("Auto-switching to MJPEG format for MJPEG mode");
|
info!("Auto-switching to MJPEG format for MJPEG mode");
|
||||||
@@ -779,6 +784,61 @@ impl VideoStreamManager {
|
|||||||
self.webrtc_streamer.request_keyframe().await
|
self.webrtc_streamer.request_keyframe().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Notify frontend about a codec-only switch (WebRTC mode unchanged, codec changed).
|
||||||
|
///
|
||||||
|
/// `set_video_codec` already rebuilt the pipeline synchronously, so we just
|
||||||
|
/// emit the events the frontend waits on: `StreamModeChanged`, `WebRTCReady`,
|
||||||
|
/// and `StreamModeReady`.
|
||||||
|
///
|
||||||
|
/// Events are spawned asynchronously so the HTTP response (carrying the
|
||||||
|
/// `transition_id`) reaches the client before the WebSocket events, giving
|
||||||
|
/// the frontend time to call `registerTransition()` first.
|
||||||
|
pub async fn notify_codec_switch(
|
||||||
|
self: &Arc<Self>,
|
||||||
|
transition_id: &str,
|
||||||
|
new_codec_str: &str,
|
||||||
|
previous_codec_str: &str,
|
||||||
|
) {
|
||||||
|
let manager = Arc::clone(self);
|
||||||
|
let transition_id = transition_id.to_string();
|
||||||
|
let new_codec = new_codec_str.to_string();
|
||||||
|
let prev_codec = previous_codec_str.to_string();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
// Small yield to ensure the HTTP response is flushed first.
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.publish_event(SystemEvent::StreamModeChanged {
|
||||||
|
transition_id: Some(transition_id.clone()),
|
||||||
|
mode: new_codec.clone(),
|
||||||
|
previous_mode: prev_codec.clone(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let is_hardware = manager.webrtc_streamer.is_hardware_encoding().await;
|
||||||
|
manager
|
||||||
|
.publish_event(SystemEvent::WebRTCReady {
|
||||||
|
transition_id: Some(transition_id.clone()),
|
||||||
|
codec: new_codec.clone(),
|
||||||
|
hardware: is_hardware,
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
manager
|
||||||
|
.publish_event(SystemEvent::StreamModeReady {
|
||||||
|
transition_id: transition_id.clone(),
|
||||||
|
mode: new_codec.clone(),
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Codec switch notified: {} -> {} (transition: {})",
|
||||||
|
prev_codec, new_codec, transition_id
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Publish event to event bus
|
/// Publish event to event bus
|
||||||
async fn publish_event(&self, event: SystemEvent) {
|
async fn publish_event(&self, event: SystemEvent) {
|
||||||
if let Some(ref events) = *self.events.read().await {
|
if let Some(ref events) = *self.events.read().await {
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ use std::time::Duration;
|
|||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
use tracing::{debug, error, info, trace, warn};
|
use tracing::{debug, error, info, trace, warn};
|
||||||
|
|
||||||
use super::device::{enumerate_devices, find_best_device, VideoDeviceInfo};
|
use super::device::{enumerate_devices, find_best_device, VideoDevice, VideoDeviceInfo};
|
||||||
use super::format::{PixelFormat, Resolution};
|
use super::format::{PixelFormat, Resolution};
|
||||||
use super::frame::{FrameBuffer, FrameBufferPool, VideoFrame};
|
use super::frame::{FrameBuffer, FrameBufferPool, VideoFrame};
|
||||||
|
use super::is_rk_hdmirx_device;
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
use crate::events::{EventBus, SystemEvent};
|
use crate::events::{EventBus, SystemEvent};
|
||||||
use crate::stream::MjpegStreamHandler;
|
use crate::stream::MjpegStreamHandler;
|
||||||
@@ -269,24 +270,7 @@ impl Streamer {
|
|||||||
.find(|d| d.path.to_string_lossy() == device_path)
|
.find(|d| d.path.to_string_lossy() == device_path)
|
||||||
.ok_or_else(|| AppError::VideoError("Video device not found".to_string()))?;
|
.ok_or_else(|| AppError::VideoError("Video device not found".to_string()))?;
|
||||||
|
|
||||||
// Validate format
|
let (format, resolution) = self.resolve_capture_config(&device, format, resolution)?;
|
||||||
let fmt_info = device
|
|
||||||
.formats
|
|
||||||
.iter()
|
|
||||||
.find(|f| f.format == format)
|
|
||||||
.ok_or_else(|| AppError::VideoError("Requested format not supported".to_string()))?;
|
|
||||||
|
|
||||||
// Validate resolution
|
|
||||||
if !fmt_info.resolutions.is_empty()
|
|
||||||
&& !fmt_info
|
|
||||||
.resolutions
|
|
||||||
.iter()
|
|
||||||
.any(|r| r.width == resolution.width && r.height == resolution.height)
|
|
||||||
{
|
|
||||||
return Err(AppError::VideoError(
|
|
||||||
"Requested resolution not supported".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// IMPORTANT: Disconnect all MJPEG clients FIRST before stopping capture
|
// IMPORTANT: Disconnect all MJPEG clients FIRST before stopping capture
|
||||||
// This prevents race conditions where clients try to reconnect and reopen the device
|
// This prevents race conditions where clients try to reconnect and reopen the device
|
||||||
@@ -385,6 +369,14 @@ impl Streamer {
|
|||||||
device: &VideoDeviceInfo,
|
device: &VideoDeviceInfo,
|
||||||
preferred: PixelFormat,
|
preferred: PixelFormat,
|
||||||
) -> Result<PixelFormat> {
|
) -> Result<PixelFormat> {
|
||||||
|
if is_rk_hdmirx_device(device) {
|
||||||
|
return device
|
||||||
|
.formats
|
||||||
|
.first()
|
||||||
|
.map(|f| f.format)
|
||||||
|
.ok_or_else(|| AppError::VideoError("No supported formats found".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
// Check if preferred format is available
|
// Check if preferred format is available
|
||||||
if device.formats.iter().any(|f| f.format == preferred) {
|
if device.formats.iter().any(|f| f.format == preferred) {
|
||||||
return Ok(preferred);
|
return Ok(preferred);
|
||||||
@@ -411,6 +403,14 @@ impl Streamer {
|
|||||||
.find(|f| &f.format == format)
|
.find(|f| &f.format == format)
|
||||||
.ok_or_else(|| AppError::VideoError("Format not found".to_string()))?;
|
.ok_or_else(|| AppError::VideoError("Format not found".to_string()))?;
|
||||||
|
|
||||||
|
if is_rk_hdmirx_device(device) {
|
||||||
|
return Ok(format_info
|
||||||
|
.resolutions
|
||||||
|
.first()
|
||||||
|
.map(|r| r.resolution())
|
||||||
|
.unwrap_or(preferred));
|
||||||
|
}
|
||||||
|
|
||||||
// Check if preferred resolution is available
|
// Check if preferred resolution is available
|
||||||
if format_info.resolutions.is_empty()
|
if format_info.resolutions.is_empty()
|
||||||
|| format_info
|
|| format_info
|
||||||
@@ -429,6 +429,17 @@ impl Streamer {
|
|||||||
.ok_or_else(|| AppError::VideoError("No resolutions available".to_string()))
|
.ok_or_else(|| AppError::VideoError("No resolutions available".to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn resolve_capture_config(
|
||||||
|
&self,
|
||||||
|
device: &VideoDeviceInfo,
|
||||||
|
requested_format: PixelFormat,
|
||||||
|
requested_resolution: Resolution,
|
||||||
|
) -> Result<(PixelFormat, Resolution)> {
|
||||||
|
let format = self.select_format(device, requested_format)?;
|
||||||
|
let resolution = self.select_resolution(device, &format, requested_resolution)?;
|
||||||
|
Ok((format, resolution))
|
||||||
|
}
|
||||||
|
|
||||||
/// Restart capture for recovery (direct capture path)
|
/// Restart capture for recovery (direct capture path)
|
||||||
async fn restart_capture(self: &Arc<Self>) -> Result<()> {
|
async fn restart_capture(self: &Arc<Self>) -> Result<()> {
|
||||||
self.direct_stop.store(false, Ordering::SeqCst);
|
self.direct_stop.store(false, Ordering::SeqCst);
|
||||||
@@ -608,12 +619,22 @@ impl Streamer {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Direct capture loop for MJPEG mode (single loop, no broadcast)
|
/// Direct capture loop for MJPEG mode.
|
||||||
fn run_direct_capture(self: Arc<Self>, device_path: PathBuf, config: StreamerConfig) {
|
///
|
||||||
|
/// The outer `'session` loop allows "soft restarts": when no signal has been
|
||||||
|
/// detected for `NOSIGNAL_SOFT_RESTART_SECS` the capture stream is closed and
|
||||||
|
/// re-opened (re-probing format/resolution) without going through the full
|
||||||
|
/// DeviceLost recovery path. This handles the common CSI/HDMI-bridge case where
|
||||||
|
/// the source switches resolution and the driver requires a new `s_fmt` call.
|
||||||
|
fn run_direct_capture(self: Arc<Self>, device_path: PathBuf, _initial_config: StreamerConfig) {
|
||||||
const MAX_RETRIES: u32 = 5;
|
const MAX_RETRIES: u32 = 5;
|
||||||
const RETRY_DELAY_MS: u64 = 200;
|
const RETRY_DELAY_MS: u64 = 200;
|
||||||
const IDLE_STOP_DELAY_SECS: u64 = 5;
|
const IDLE_STOP_DELAY_SECS: u64 = 5;
|
||||||
const BUFFER_COUNT: u32 = 2;
|
const BUFFER_COUNT: u32 = 2;
|
||||||
|
/// After this many seconds without signal, close+re-open the device.
|
||||||
|
const NOSIGNAL_SOFT_RESTART_SECS: u64 = 8;
|
||||||
|
/// Placeholder frame re-send interval while in NoSignal state (iterations of 100 ms).
|
||||||
|
const NOSIGNAL_PLACEHOLDER_INTERVAL: u32 = 10; // every ~1 s
|
||||||
|
|
||||||
let handle = tokio::runtime::Handle::current();
|
let handle = tokio::runtime::Handle::current();
|
||||||
let mut last_state = StreamerState::Streaming;
|
let mut last_state = StreamerState::Streaming;
|
||||||
@@ -628,6 +649,19 @@ impl Streamer {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// How many soft-restart cycles have been attempted (for exponential back-off).
|
||||||
|
let mut no_signal_restart_count: u32 = 0;
|
||||||
|
|
||||||
|
'session: loop {
|
||||||
|
if self.direct_stop.load(Ordering::Relaxed) {
|
||||||
|
break 'session;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-read config at the start of each session so that a re_init_device()
|
||||||
|
// call (from a previous soft-restart or recovery) is reflected here.
|
||||||
|
let config = handle.block_on(async { self.config.read().await.clone() });
|
||||||
|
|
||||||
|
// ── Open the capture stream ─────────────────────────────────────────
|
||||||
let mut stream_opt: Option<V4l2rCaptureStream> = None;
|
let mut stream_opt: Option<V4l2rCaptureStream> = None;
|
||||||
let mut last_error: Option<String> = None;
|
let mut last_error: Option<String> = None;
|
||||||
|
|
||||||
@@ -678,9 +712,7 @@ impl Streamer {
|
|||||||
);
|
);
|
||||||
self.mjpeg_handler.set_offline();
|
self.mjpeg_handler.set_offline();
|
||||||
set_state(StreamerState::Error);
|
set_state(StreamerState::Error);
|
||||||
self.direct_active.store(false, Ordering::SeqCst);
|
break 'session;
|
||||||
self.current_fps.store(0, Ordering::Relaxed);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -714,7 +746,15 @@ impl Streamer {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
while !self.direct_stop.load(Ordering::Relaxed) {
|
// None = signal is present; Some(Instant) = when signal was first lost.
|
||||||
|
let mut no_signal_since: Option<std::time::Instant> = None;
|
||||||
|
// Counter for periodic placeholder pushes during NoSignal.
|
||||||
|
let mut nosignal_placeholder_counter: u32 = 0;
|
||||||
|
// Whether the inner 'capture loop should trigger a soft restart.
|
||||||
|
let mut need_soft_restart = false;
|
||||||
|
|
||||||
|
// ── Inner capture loop ──────────────────────────────────────────────
|
||||||
|
'capture: while !self.direct_stop.load(Ordering::Relaxed) {
|
||||||
let mjpeg_clients = self.mjpeg_handler.client_count();
|
let mjpeg_clients = self.mjpeg_handler.client_count();
|
||||||
if mjpeg_clients == 0 {
|
if mjpeg_clients == 0 {
|
||||||
if idle_since.is_none() {
|
if idle_since.is_none() {
|
||||||
@@ -728,7 +768,7 @@ impl Streamer {
|
|||||||
);
|
);
|
||||||
self.mjpeg_handler.set_offline();
|
self.mjpeg_handler.set_offline();
|
||||||
set_state(StreamerState::Ready);
|
set_state(StreamerState::Ready);
|
||||||
break;
|
break 'capture;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if idle_since.is_some() {
|
} else if idle_since.is_some() {
|
||||||
@@ -743,14 +783,45 @@ impl Streamer {
|
|||||||
if e.kind() == std::io::ErrorKind::TimedOut {
|
if e.kind() == std::io::ErrorKind::TimedOut {
|
||||||
if signal_present {
|
if signal_present {
|
||||||
signal_present = false;
|
signal_present = false;
|
||||||
self.mjpeg_handler.set_offline();
|
// Don't call set_offline() – instead keep the MJPEG stream
|
||||||
|
// alive by pushing a placeholder frame so clients stay
|
||||||
|
// connected and see the "no signal" image.
|
||||||
|
self.mjpeg_handler.push_no_signal_placeholder();
|
||||||
set_state(StreamerState::NoSignal);
|
set_state(StreamerState::NoSignal);
|
||||||
|
no_signal_since = Some(std::time::Instant::now());
|
||||||
self.current_fps.store(0, Ordering::Relaxed);
|
self.current_fps.store(0, Ordering::Relaxed);
|
||||||
fps_frame_count = 0;
|
fps_frame_count = 0;
|
||||||
last_fps_time = std::time::Instant::now();
|
last_fps_time = std::time::Instant::now();
|
||||||
|
nosignal_placeholder_counter = 0;
|
||||||
|
} else {
|
||||||
|
// Already in NoSignal – re-send placeholder periodically so
|
||||||
|
// the HTTP keepalive timer does not expire.
|
||||||
|
nosignal_placeholder_counter =
|
||||||
|
nosignal_placeholder_counter.wrapping_add(1);
|
||||||
|
if nosignal_placeholder_counter >= NOSIGNAL_PLACEHOLDER_INTERVAL {
|
||||||
|
nosignal_placeholder_counter = 0;
|
||||||
|
self.mjpeg_handler.push_no_signal_placeholder();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Soft-restart after exponential back-off.
|
||||||
|
if let Some(since) = no_signal_since {
|
||||||
|
let backoff_secs = NOSIGNAL_SOFT_RESTART_SECS
|
||||||
|
.saturating_mul(2u64.pow(no_signal_restart_count.min(2)))
|
||||||
|
.min(30);
|
||||||
|
if since.elapsed().as_secs() >= backoff_secs {
|
||||||
|
info!(
|
||||||
|
"NoSignal for {}s, attempting soft restart (attempt {})",
|
||||||
|
backoff_secs,
|
||||||
|
no_signal_restart_count + 1
|
||||||
|
);
|
||||||
|
need_soft_restart = true;
|
||||||
|
break 'capture;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
continue;
|
continue 'capture;
|
||||||
}
|
}
|
||||||
|
|
||||||
let is_device_lost = match e.raw_os_error() {
|
let is_device_lost = match e.raw_os_error() {
|
||||||
@@ -777,7 +848,7 @@ impl Streamer {
|
|||||||
streamer.start_device_recovery_internal().await;
|
streamer.start_device_recovery_internal().await;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
break;
|
break 'capture;
|
||||||
}
|
}
|
||||||
|
|
||||||
let key = classify_capture_error(&e);
|
let key = classify_capture_error(&e);
|
||||||
@@ -792,13 +863,13 @@ impl Streamer {
|
|||||||
let counter = suppressed_capture_errors.entry(key).or_insert(0);
|
let counter = suppressed_capture_errors.entry(key).or_insert(0);
|
||||||
*counter = counter.saturating_add(1);
|
*counter = counter.saturating_add(1);
|
||||||
}
|
}
|
||||||
continue;
|
continue 'capture;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let frame_size = meta.bytes_used;
|
let frame_size = meta.bytes_used;
|
||||||
if frame_size < MIN_CAPTURE_FRAME_SIZE {
|
if frame_size < MIN_CAPTURE_FRAME_SIZE {
|
||||||
continue;
|
continue 'capture;
|
||||||
}
|
}
|
||||||
|
|
||||||
validate_counter = validate_counter.wrapping_add(1);
|
validate_counter = validate_counter.wrapping_add(1);
|
||||||
@@ -806,7 +877,7 @@ impl Streamer {
|
|||||||
&& validate_counter.is_multiple_of(JPEG_VALIDATE_INTERVAL)
|
&& validate_counter.is_multiple_of(JPEG_VALIDATE_INTERVAL)
|
||||||
&& !VideoFrame::is_valid_jpeg_bytes(&owned[..frame_size])
|
&& !VideoFrame::is_valid_jpeg_bytes(&owned[..frame_size])
|
||||||
{
|
{
|
||||||
continue;
|
continue 'capture;
|
||||||
}
|
}
|
||||||
|
|
||||||
owned.truncate(frame_size);
|
owned.truncate(frame_size);
|
||||||
@@ -820,7 +891,9 @@ impl Streamer {
|
|||||||
|
|
||||||
if !signal_present {
|
if !signal_present {
|
||||||
signal_present = true;
|
signal_present = true;
|
||||||
self.mjpeg_handler.set_online();
|
no_signal_since = None;
|
||||||
|
no_signal_restart_count = 0;
|
||||||
|
// Stream was kept online (placeholder pushes), just update state.
|
||||||
set_state(StreamerState::Streaming);
|
set_state(StreamerState::Streaming);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -835,15 +908,110 @@ impl Streamer {
|
|||||||
self.current_fps
|
self.current_fps
|
||||||
.store((current_fps * 100.0) as u32, Ordering::Relaxed);
|
.store((current_fps * 100.0) as u32, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
} // 'capture
|
||||||
|
|
||||||
|
// ── After inner loop ────────────────────────────────────────────────
|
||||||
|
// The stream is dropped here, releasing the device FD.
|
||||||
|
drop(stream);
|
||||||
|
|
||||||
|
if self.direct_stop.load(Ordering::Relaxed) {
|
||||||
|
break 'session;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !need_soft_restart {
|
||||||
|
// Normal exit (idle / device-lost / stop).
|
||||||
|
break 'session;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Soft restart path ───────────────────────────────────────────────
|
||||||
|
no_signal_restart_count = no_signal_restart_count.saturating_add(1);
|
||||||
|
|
||||||
|
// Re-probe the device to pick up a changed resolution/format.
|
||||||
|
match VideoDevice::open_readonly(&device_path).and_then(|d| d.info()) {
|
||||||
|
Ok(device_info) => {
|
||||||
|
handle.block_on(async {
|
||||||
|
let fmt;
|
||||||
|
let res;
|
||||||
|
{
|
||||||
|
let cfg = self.config.read().await;
|
||||||
|
fmt = self
|
||||||
|
.select_format(&device_info, cfg.format)
|
||||||
|
.unwrap_or(cfg.format);
|
||||||
|
res = self
|
||||||
|
.select_resolution(&device_info, &fmt, cfg.resolution)
|
||||||
|
.unwrap_or(cfg.resolution);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let mut cfg = self.config.write().await;
|
||||||
|
cfg.format = fmt;
|
||||||
|
cfg.resolution = res;
|
||||||
|
}
|
||||||
|
*self.current_device.write().await = Some(device_info);
|
||||||
|
info!(
|
||||||
|
"Soft restart: re-probed device → {}x{} {:?}",
|
||||||
|
res.width, res.height, fmt
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Soft restart: failed to re-probe device: {}", e);
|
||||||
|
// Brief wait before retrying to avoid spinning.
|
||||||
|
let wait = 2u64.pow(no_signal_restart_count.min(3));
|
||||||
|
std::thread::sleep(Duration::from_secs(wait));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset no_signal_since so the back-off timer is fresh for the new session.
|
||||||
|
// no_signal_since will be re-set if the new session immediately times out.
|
||||||
|
|
||||||
|
// Continue 'session → re-open V4l2rCaptureStream with updated config.
|
||||||
|
} // 'session
|
||||||
|
|
||||||
self.direct_active.store(false, Ordering::SeqCst);
|
self.direct_active.store(false, Ordering::SeqCst);
|
||||||
self.current_fps.store(0, Ordering::Relaxed);
|
self.current_fps.store(0, Ordering::Relaxed);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if streaming
|
/// Check if streaming (or in NoSignal state — capture thread is still running)
|
||||||
pub async fn is_streaming(&self) -> bool {
|
pub async fn is_streaming(&self) -> bool {
|
||||||
self.state().await == StreamerState::Streaming
|
matches!(
|
||||||
|
self.state().await,
|
||||||
|
StreamerState::Streaming | StreamerState::NoSignal
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-probe a device and update the stored config/device info.
|
||||||
|
///
|
||||||
|
/// Called during recovery or after a NoSignal soft restart so that a
|
||||||
|
/// resolution / format change on the source side is picked up before
|
||||||
|
/// the capture stream is re-opened.
|
||||||
|
pub async fn re_init_device(self: &Arc<Self>, device_path: &str) -> Result<()> {
|
||||||
|
let device = VideoDevice::open_readonly(device_path)
|
||||||
|
.map_err(|e| AppError::VideoError(format!("Cannot open device for re-init: {}", e)))?;
|
||||||
|
let device_info = device.info()?;
|
||||||
|
|
||||||
|
let (format, resolution) = {
|
||||||
|
let config = self.config.read().await;
|
||||||
|
let fmt = self
|
||||||
|
.select_format(&device_info, config.format)
|
||||||
|
.unwrap_or(config.format);
|
||||||
|
let res = self
|
||||||
|
.select_resolution(&device_info, &fmt, config.resolution)
|
||||||
|
.unwrap_or(config.resolution);
|
||||||
|
(fmt, res)
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut cfg = self.config.write().await;
|
||||||
|
cfg.format = format;
|
||||||
|
cfg.resolution = resolution;
|
||||||
|
}
|
||||||
|
*self.current_device.write().await = Some(device_info);
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Device re-initialized: {}x{} {:?}",
|
||||||
|
resolution.width, resolution.height, format
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get stream statistics
|
/// Get stream statistics
|
||||||
@@ -985,6 +1153,15 @@ impl Streamer {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-probe device to pick up resolution/format changes
|
||||||
|
if let Err(e) = streamer.re_init_device(&device_path).await {
|
||||||
|
debug!(
|
||||||
|
"Failed to re-probe device format (attempt {}): {}",
|
||||||
|
attempt, e
|
||||||
|
);
|
||||||
|
// Don't skip – device exists, try restart anyway
|
||||||
|
}
|
||||||
|
|
||||||
// Try to restart capture
|
// Try to restart capture
|
||||||
match streamer.restart_capture().await {
|
match streamer.restart_capture().await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use v4l2r::ioctl::{
|
|||||||
QBuffer, QueryBuffer, V4l2Buffer,
|
QBuffer, QueryBuffer, V4l2Buffer,
|
||||||
};
|
};
|
||||||
use v4l2r::memory::{MemoryType, MmapHandle};
|
use v4l2r::memory::{MemoryType, MmapHandle};
|
||||||
|
use v4l2r::nix::errno::Errno;
|
||||||
use v4l2r::{Format as V4l2rFormat, PixelFormat as V4l2rPixelFormat, QueueType};
|
use v4l2r::{Format as V4l2rFormat, PixelFormat as V4l2rPixelFormat, QueueType};
|
||||||
|
|
||||||
use crate::error::{AppError, Result};
|
use crate::error::{AppError, Result};
|
||||||
@@ -91,8 +92,11 @@ impl V4l2rCaptureStream {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if fps > 0 {
|
if fps > 0 {
|
||||||
if let Err(e) = set_fps(&fd, queue, fps) {
|
match set_fps(&fd, queue, fps) {
|
||||||
warn!("Failed to set hardware FPS: {}", e);
|
Ok(()) => {}
|
||||||
|
Err(ioctl::GParmError::IoctlError(err))
|
||||||
|
if matches!(err, Errno::ENOTTY | Errno::ENOSYS | Errno::EOPNOTSUPP) => {}
|
||||||
|
Err(e) => warn!("Failed to set hardware FPS: {}", e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,7 +262,7 @@ impl Drop for V4l2rCaptureStream {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_fps(fd: &File, queue: QueueType, fps: u32) -> Result<()> {
|
fn set_fps(fd: &File, queue: QueueType, fps: u32) -> std::result::Result<(), ioctl::GParmError> {
|
||||||
let mut params = unsafe { std::mem::zeroed::<v4l2_streamparm>() };
|
let mut params = unsafe { std::mem::zeroed::<v4l2_streamparm>() };
|
||||||
params.type_ = queue as u32;
|
params.type_ = queue as u32;
|
||||||
params.parm = v4l2_streamparm__bindgen_ty_1 {
|
params.parm = v4l2_streamparm__bindgen_ty_1 {
|
||||||
@@ -271,7 +275,6 @@ fn set_fps(fd: &File, queue: QueueType, fps: u32) -> Result<()> {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let _actual: v4l2_streamparm = ioctl::s_parm(fd, params)
|
let _actual: v4l2_streamparm = ioctl::s_parm(fd, params)?;
|
||||||
.map_err(|e| AppError::VideoError(format!("Failed to set FPS: {}", e)))?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ use crate::error::AppError;
|
|||||||
use crate::rtsp::RtspServiceStatus;
|
use crate::rtsp::RtspServiceStatus;
|
||||||
use crate::rustdesk::config::RustDeskConfig;
|
use crate::rustdesk::config::RustDeskConfig;
|
||||||
use crate::video::encoder::BitratePreset;
|
use crate::video::encoder::BitratePreset;
|
||||||
use serde::Deserialize;
|
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use typeshare::typeshare;
|
use typeshare::typeshare;
|
||||||
|
|
||||||
@@ -660,6 +661,26 @@ impl AudioConfigUpdate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===== RustDesk Config =====
|
// ===== RustDesk Config =====
|
||||||
|
|
||||||
|
/// hbbs/hbbr `-k` relay key: standard Base64 encoding of exactly 32 bytes (typically 44 chars with padding).
|
||||||
|
fn validate_rustdesk_relay_key(key: &str) -> Result<(), AppError> {
|
||||||
|
let decoded = STANDARD.decode(key.as_bytes()).map_err(|_| {
|
||||||
|
AppError::BadRequest(
|
||||||
|
"Relay key must be standard Base64 (32 raw bytes, e.g. hbbs/hbbr -k output)".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if decoded.len() != 32 {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
format!(
|
||||||
|
"Relay key must decode to exactly 32 bytes (got {} bytes after Base64 decode)",
|
||||||
|
decoded.len()
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct RustDeskConfigUpdate {
|
pub struct RustDeskConfigUpdate {
|
||||||
@@ -698,6 +719,12 @@ impl RustDeskConfigUpdate {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(ref key) = self.relay_key {
|
||||||
|
let trimmed = key.trim();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
validate_rustdesk_relay_key(trimmed)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -716,10 +743,11 @@ impl RustDeskConfigUpdate {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
if let Some(ref key) = self.relay_key {
|
if let Some(ref key) = self.relay_key {
|
||||||
config.relay_key = if key.is_empty() {
|
let trimmed = key.trim();
|
||||||
|
config.relay_key = if trimmed.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(key.clone())
|
Some(trimmed.to_string())
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if let Some(ref password) = self.device_password {
|
if let Some(ref password) = self.device_password {
|
||||||
@@ -849,6 +877,45 @@ impl RtspConfigUpdate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===== Web Config =====
|
// ===== Web Config =====
|
||||||
|
|
||||||
|
/// Web server settings returned by `GET` / `PATCH /api/config/web`.
|
||||||
|
///
|
||||||
|
/// Public API shape: certificate paths on disk are not exposed. The full stored model is `WebConfig` in `config::schema`.
|
||||||
|
#[typeshare]
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct WebConfigResponse {
|
||||||
|
pub http_port: u16,
|
||||||
|
pub https_port: u16,
|
||||||
|
pub bind_addresses: Vec<String>,
|
||||||
|
pub bind_address: String,
|
||||||
|
pub https_enabled: bool,
|
||||||
|
/// Whether a custom TLS certificate is active (non-empty cert + key paths in stored config).
|
||||||
|
pub has_custom_cert: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WebConfigResponse {
|
||||||
|
pub fn from_stored(web: &WebConfig) -> Self {
|
||||||
|
let has_custom_cert = web
|
||||||
|
.ssl_cert_path
|
||||||
|
.as_deref()
|
||||||
|
.map(|p| !p.is_empty())
|
||||||
|
.unwrap_or(false)
|
||||||
|
&& web
|
||||||
|
.ssl_key_path
|
||||||
|
.as_deref()
|
||||||
|
.map(|p| !p.is_empty())
|
||||||
|
.unwrap_or(false);
|
||||||
|
Self {
|
||||||
|
http_port: web.http_port,
|
||||||
|
https_port: web.https_port,
|
||||||
|
bind_addresses: web.bind_addresses.clone(),
|
||||||
|
bind_address: web.bind_address.clone(),
|
||||||
|
https_enabled: web.https_enabled,
|
||||||
|
has_custom_cert,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[typeshare]
|
#[typeshare]
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct WebConfigUpdate {
|
pub struct WebConfigUpdate {
|
||||||
@@ -857,6 +924,12 @@ pub struct WebConfigUpdate {
|
|||||||
pub bind_addresses: Option<Vec<String>>,
|
pub bind_addresses: Option<Vec<String>>,
|
||||||
pub bind_address: Option<String>,
|
pub bind_address: Option<String>,
|
||||||
pub https_enabled: Option<bool>,
|
pub https_enabled: Option<bool>,
|
||||||
|
/// PEM-encoded certificate content (must be provided together with ssl_key_pem)
|
||||||
|
pub ssl_cert_pem: Option<String>,
|
||||||
|
/// PEM-encoded private key content (must be provided together with ssl_cert_pem)
|
||||||
|
pub ssl_key_pem: Option<String>,
|
||||||
|
/// Set to true to remove the custom certificate and revert to self-signed
|
||||||
|
pub clear_custom_cert: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WebConfigUpdate {
|
impl WebConfigUpdate {
|
||||||
@@ -883,6 +956,22 @@ impl WebConfigUpdate {
|
|||||||
return Err(AppError::BadRequest("Invalid bind address".into()));
|
return Err(AppError::BadRequest("Invalid bind address".into()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Cert and key must be provided together (cryptographic validity is checked in the
|
||||||
|
// handler via `RustlsConfig::from_pem`, same stack as the running HTTPS server).
|
||||||
|
match (&self.ssl_cert_pem, &self.ssl_key_pem) {
|
||||||
|
(Some(_cert), Some(_key)) => {}
|
||||||
|
(Some(_), None) => {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"ssl_key_pem is required when ssl_cert_pem is provided".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
(None, Some(_)) => {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"ssl_cert_pem is required when ssl_key_pem is provided".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
(None, None) => {}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -907,6 +996,8 @@ impl WebConfigUpdate {
|
|||||||
if let Some(enabled) = self.https_enabled {
|
if let Some(enabled) = self.https_enabled {
|
||||||
config.https_enabled = enabled;
|
config.https_enabled = enabled;
|
||||||
}
|
}
|
||||||
|
// ssl_cert_pem, ssl_key_pem, clear_custom_cert are handled at the handler level
|
||||||
|
// (they require async file I/O before updating config paths)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -988,4 +1079,30 @@ mod tests {
|
|||||||
|
|
||||||
assert!(update.validate_with_current(¤t).is_err());
|
assert!(update.validate_with_current(¤t).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rustdesk_relay_key_accepts_hbbs_style_base64_32_bytes() {
|
||||||
|
let update = RustDeskConfigUpdate {
|
||||||
|
enabled: None,
|
||||||
|
rendezvous_server: None,
|
||||||
|
relay_server: None,
|
||||||
|
relay_key: Some("pLU0pEj2IZnNVKzrIO1pIdwGA3dOVJJLkFIYGOCGH1E=".to_string()),
|
||||||
|
device_password: None,
|
||||||
|
};
|
||||||
|
assert!(update.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rustdesk_relay_key_rejects_non_32_byte_payload() {
|
||||||
|
// Standard Base64 for 16 zero bytes (not 32).
|
||||||
|
let not_32 = "AAAAAAAAAAAAAAAAAAAAAA==".to_string();
|
||||||
|
let update = RustDeskConfigUpdate {
|
||||||
|
enabled: None,
|
||||||
|
rendezvous_server: None,
|
||||||
|
relay_server: None,
|
||||||
|
relay_key: Some(not_32),
|
||||||
|
device_password: None,
|
||||||
|
};
|
||||||
|
assert!(update.validate().is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,103 @@
|
|||||||
//! Web 服务器配置 Handler
|
//! Web 服务器配置 Handler
|
||||||
|
|
||||||
use axum::{extract::State, Json};
|
use axum::{extract::State, Json};
|
||||||
|
use axum_server::tls_rustls::RustlsConfig;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::config::WebConfig;
|
use crate::error::{AppError, Result};
|
||||||
use crate::error::Result;
|
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
use super::types::WebConfigUpdate;
|
use super::types::{WebConfigResponse, WebConfigUpdate};
|
||||||
|
|
||||||
/// 获取 Web 配置
|
/// 获取 Web 配置
|
||||||
pub async fn get_web_config(State(state): State<Arc<AppState>>) -> Json<WebConfig> {
|
pub async fn get_web_config(
|
||||||
Json(state.config.get().web.clone())
|
State(state): State<Arc<AppState>>,
|
||||||
|
) -> Json<WebConfigResponse> {
|
||||||
|
Json(WebConfigResponse::from_stored(&state.config.get().web))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新 Web 配置
|
/// 更新 Web 配置(支持 PEM 证书上传)
|
||||||
pub async fn update_web_config(
|
pub async fn update_web_config(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
Json(req): Json<WebConfigUpdate>,
|
Json(req): Json<WebConfigUpdate>,
|
||||||
) -> Result<Json<WebConfig>> {
|
) -> Result<Json<WebConfigResponse>> {
|
||||||
req.validate()?;
|
req.validate()?;
|
||||||
|
|
||||||
|
// Determine certificate path changes (requires async file I/O before config update)
|
||||||
|
// Some(Some((cert, key))) = write new cert
|
||||||
|
// Some(None) = clear custom cert
|
||||||
|
// None = no cert change
|
||||||
|
let cert_path_update: Option<Option<(String, String)>> =
|
||||||
|
if let (Some(cert_pem), Some(key_pem)) = (&req.ssl_cert_pem, &req.ssl_key_pem) {
|
||||||
|
RustlsConfig::from_pem(cert_pem.as_bytes().to_vec(), key_pem.as_bytes().to_vec())
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
AppError::BadRequest(
|
||||||
|
format!(
|
||||||
|
"Invalid TLS certificate or private key (PEM must match what the HTTPS server can load): {e}"
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let cert_dir = state.data_dir().join("certs");
|
||||||
|
tokio::fs::create_dir_all(&cert_dir)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to create cert dir: {e}")))?;
|
||||||
|
let cert_path = cert_dir.join("custom.crt");
|
||||||
|
let key_path = cert_dir.join("custom.key");
|
||||||
|
tokio::fs::write(&cert_path, cert_pem.as_bytes())
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to write certificate: {e}")))?;
|
||||||
|
tokio::fs::write(&key_path, key_pem.as_bytes())
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(format!("Failed to write private key: {e}")))?;
|
||||||
|
Some(Some((
|
||||||
|
cert_path.to_string_lossy().into_owned(),
|
||||||
|
key_path.to_string_lossy().into_owned(),
|
||||||
|
)))
|
||||||
|
} else if req.clear_custom_cert.unwrap_or(false) {
|
||||||
|
let cert_dir = state.data_dir().join("certs");
|
||||||
|
let _ = tokio::fs::remove_file(cert_dir.join("custom.crt")).await;
|
||||||
|
let _ = tokio::fs::remove_file(cert_dir.join("custom.key")).await;
|
||||||
|
Some(None)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
state
|
state
|
||||||
.config
|
.config
|
||||||
.update(|config| {
|
.update(move |config| {
|
||||||
req.apply_to(&mut config.web);
|
req.apply_to(&mut config.web);
|
||||||
|
match cert_path_update {
|
||||||
|
Some(Some((cert_path, key_path))) => {
|
||||||
|
config.web.ssl_cert_path = Some(cert_path);
|
||||||
|
config.web.ssl_key_path = Some(key_path);
|
||||||
|
}
|
||||||
|
Some(None) => {
|
||||||
|
config.web.ssl_cert_path = None;
|
||||||
|
config.web.ssl_key_path = None;
|
||||||
|
}
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(Json(state.config.get().web.clone()))
|
Ok(Json(WebConfigResponse::from_stored(&state.config.get().web)))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rustls::crypto::{ring, CryptoProvider};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rustls_accepts_rcgen_self_signed_pem() {
|
||||||
|
let _ = CryptoProvider::install_default(ring::default_provider());
|
||||||
|
let cert = rcgen::generate_simple_self_signed(vec!["localhost".into()]).unwrap();
|
||||||
|
let cert_pem = cert.cert.pem();
|
||||||
|
let key_pem = cert.signing_key.serialize_pem();
|
||||||
|
RustlsConfig::from_pem(cert_pem.into_bytes(), key_pem.into_bytes())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -256,12 +256,14 @@ pub async fn update_gostc_config(
|
|||||||
let new_config = state.config.get();
|
let new_config = state.config.get();
|
||||||
let is_enabled = new_config.extensions.gostc.enabled;
|
let is_enabled = new_config.extensions.gostc.enabled;
|
||||||
let has_key = !new_config.extensions.gostc.key.is_empty();
|
let has_key = !new_config.extensions.gostc.key.is_empty();
|
||||||
|
let has_addr = !new_config.extensions.gostc.addr.trim().is_empty();
|
||||||
|
|
||||||
if was_enabled && !is_enabled {
|
if was_enabled && !is_enabled {
|
||||||
state.extensions.stop(ExtensionId::Gostc).await.ok();
|
state.extensions.stop(ExtensionId::Gostc).await.ok();
|
||||||
} else if !was_enabled
|
} else if !was_enabled
|
||||||
&& is_enabled
|
&& is_enabled
|
||||||
&& has_key
|
&& has_key
|
||||||
|
&& has_addr
|
||||||
&& state.extensions.check_available(ExtensionId::Gostc)
|
&& state.extensions.check_available(ExtensionId::Gostc)
|
||||||
{
|
{
|
||||||
state
|
state
|
||||||
|
|||||||
@@ -1001,7 +1001,7 @@ pub struct VideoFormat {
|
|||||||
pub struct VideoResolution {
|
pub struct VideoResolution {
|
||||||
pub width: u32,
|
pub width: u32,
|
||||||
pub height: u32,
|
pub height: u32,
|
||||||
pub fps: Vec<u32>,
|
pub fps: Vec<f64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -1282,7 +1282,29 @@ pub async fn stream_mode_set(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set video codec if switching to WebRTC mode with specific codec
|
let requested_mode_str = match (&new_mode, &video_codec) {
|
||||||
|
(StreamMode::Mjpeg, _) => "mjpeg",
|
||||||
|
(StreamMode::WebRTC, Some(VideoCodecType::H264)) => "h264",
|
||||||
|
(StreamMode::WebRTC, Some(VideoCodecType::H265)) => "h265",
|
||||||
|
(StreamMode::WebRTC, Some(VideoCodecType::VP8)) => "vp8",
|
||||||
|
(StreamMode::WebRTC, Some(VideoCodecType::VP9)) => "vp9",
|
||||||
|
(StreamMode::WebRTC, None) => "webrtc",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Detect codec-only switch: already in WebRTC mode, just changing codec.
|
||||||
|
// switch_mode_transaction treats this as "no switch needed" since StreamMode
|
||||||
|
// is still WebRTC, so we handle codec change + event emission here.
|
||||||
|
let current_mode = state.stream_manager.current_mode().await;
|
||||||
|
let prev_codec = state
|
||||||
|
.stream_manager
|
||||||
|
.webrtc_streamer()
|
||||||
|
.current_video_codec()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let codec_changed = video_codec.is_some_and(|c| c != prev_codec);
|
||||||
|
let is_codec_only_switch =
|
||||||
|
current_mode == StreamMode::WebRTC && new_mode == StreamMode::WebRTC && codec_changed;
|
||||||
|
|
||||||
if let Some(codec) = video_codec {
|
if let Some(codec) = video_codec {
|
||||||
info!("Setting WebRTC video codec to {:?}", codec);
|
info!("Setting WebRTC video codec to {:?}", codec);
|
||||||
if let Err(e) = state
|
if let Err(e) = state
|
||||||
@@ -1295,22 +1317,30 @@ pub async fn stream_mode_set(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For codec-only switch, emit events directly instead of going through
|
||||||
|
// switch_mode_transaction (which short-circuits when mode is unchanged).
|
||||||
|
if is_codec_only_switch {
|
||||||
|
let transition_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
|
||||||
|
state
|
||||||
|
.stream_manager
|
||||||
|
.notify_codec_switch(&transition_id, requested_mode_str, &codec_to_id(prev_codec))
|
||||||
|
.await;
|
||||||
|
|
||||||
|
return Ok(Json(StreamModeResponse {
|
||||||
|
success: true,
|
||||||
|
mode: requested_mode_str.to_string(),
|
||||||
|
transition_id: Some(transition_id),
|
||||||
|
switching: false,
|
||||||
|
message: Some(format!("Codec switched to {}", requested_mode_str)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
let tx = state
|
let tx = state
|
||||||
.stream_manager
|
.stream_manager
|
||||||
.switch_mode_transaction(new_mode.clone())
|
.switch_mode_transaction(new_mode.clone())
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// Return the requested codec identifier (for UI display). The actual active mode
|
|
||||||
// may differ if the request was rejected due to an in-progress switch.
|
|
||||||
let requested_mode_str = match (&new_mode, &video_codec) {
|
|
||||||
(StreamMode::Mjpeg, _) => "mjpeg",
|
|
||||||
(StreamMode::WebRTC, Some(VideoCodecType::H264)) => "h264",
|
|
||||||
(StreamMode::WebRTC, Some(VideoCodecType::H265)) => "h265",
|
|
||||||
(StreamMode::WebRTC, Some(VideoCodecType::VP8)) => "vp8",
|
|
||||||
(StreamMode::WebRTC, Some(VideoCodecType::VP9)) => "vp9",
|
|
||||||
(StreamMode::WebRTC, None) => "webrtc",
|
|
||||||
};
|
|
||||||
|
|
||||||
let active_mode_str = match state.stream_manager.current_mode().await {
|
let active_mode_str = match state.stream_manager.current_mode().await {
|
||||||
StreamMode::Mjpeg => "mjpeg".to_string(),
|
StreamMode::Mjpeg => "mjpeg".to_string(),
|
||||||
StreamMode::WebRTC => {
|
StreamMode::WebRTC => {
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ pub fn placeholder_html() -> &'static str {
|
|||||||
<h1>One-KVM</h1>
|
<h1>One-KVM</h1>
|
||||||
<p>Frontend not built yet.</p>
|
<p>Frontend not built yet.</p>
|
||||||
<p>Please build the frontend or access the API directly.</p>
|
<p>Please build the frontend or access the API directly.</p>
|
||||||
<div class="version">v0.1.7</div>
|
<div class="version">v0.1.9</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>"#
|
</html>"#
|
||||||
|
|||||||
@@ -787,6 +787,22 @@ impl WebRtcStreamer {
|
|||||||
count
|
count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Close all sessions and wait for the video pipeline to fully release the
|
||||||
|
/// capture device. Use this when the caller needs the V4L2 device immediately
|
||||||
|
/// afterwards (e.g. switching to MJPEG mode).
|
||||||
|
pub async fn close_all_sessions_and_release_device(&self) -> usize {
|
||||||
|
let count = self.close_all_sessions().await;
|
||||||
|
|
||||||
|
if let Some(ref pipeline) = *self.video_pipeline.read().await {
|
||||||
|
pipeline
|
||||||
|
.stop_and_wait(std::time::Duration::from_secs(3))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
*self.video_pipeline.write().await = None;
|
||||||
|
|
||||||
|
count
|
||||||
|
}
|
||||||
|
|
||||||
/// Get session count
|
/// Get session count
|
||||||
pub async fn session_count(&self) -> usize {
|
pub async fn session_count(&self) -> usize {
|
||||||
self.sessions.read().await.len()
|
self.sessions.read().await.len()
|
||||||
|
|||||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "0.1.7",
|
"version": "0.1.9",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"version": "0.1.7",
|
"version": "0.1.9",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vueuse/core": "^14.1.0",
|
"@vueuse/core": "^14.1.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "web",
|
"name": "web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.1.7",
|
"version": "0.1.9",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ import type {
|
|||||||
GostcConfigUpdate,
|
GostcConfigUpdate,
|
||||||
EasytierConfig,
|
EasytierConfig,
|
||||||
EasytierConfigUpdate,
|
EasytierConfigUpdate,
|
||||||
|
WebConfigResponse,
|
||||||
|
WebConfigUpdate,
|
||||||
} from '@/types/generated'
|
} from '@/types/generated'
|
||||||
|
|
||||||
import { request } from './request'
|
import { request } from './request'
|
||||||
@@ -384,36 +386,24 @@ export const rtspConfigApi = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===== Web 服务器配置 API =====
|
// ===== Web 服务器配置 API =====
|
||||||
|
// `/config/web` 使用 `WebConfigResponse` / `WebConfigUpdate`(由 typeshare 自 Rust 生成)。
|
||||||
|
|
||||||
/** Web 服务器配置 */
|
/** REST `/config/web` 响应(`WebConfigResponse` 别名,兼容旧命名) */
|
||||||
export interface WebConfig {
|
export type WebConfig = WebConfigResponse
|
||||||
http_port: number
|
|
||||||
https_port: number
|
|
||||||
bind_addresses: string[]
|
|
||||||
bind_address: string
|
|
||||||
https_enabled: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Web 服务器配置更新 */
|
export type { WebConfigUpdate }
|
||||||
export interface WebConfigUpdate {
|
|
||||||
http_port?: number
|
|
||||||
https_port?: number
|
|
||||||
bind_addresses?: string[]
|
|
||||||
bind_address?: string
|
|
||||||
https_enabled?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export const webConfigApi = {
|
export const webConfigApi = {
|
||||||
/**
|
/**
|
||||||
* 获取 Web 服务器配置
|
* 获取 Web 服务器配置
|
||||||
*/
|
*/
|
||||||
get: () => request<WebConfig>('/config/web'),
|
get: () => request<WebConfigResponse>('/config/web'),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新 Web 服务器配置
|
* 更新 Web 服务器配置(含可选的证书上传)
|
||||||
*/
|
*/
|
||||||
update: (config: WebConfigUpdate) =>
|
update: (config: WebConfigUpdate) =>
|
||||||
request<WebConfig>('/config/web', {
|
request<WebConfigResponse>('/config/web', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: JSON.stringify(config),
|
body: JSON.stringify(config),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -8,10 +8,14 @@ const API_BASE = '/api'
|
|||||||
// Auth API
|
// Auth API
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
login: (username: string, password: string) =>
|
login: (username: string, password: string) =>
|
||||||
request<{ success: boolean; message?: string }>('/auth/login', {
|
request<{ success: boolean; message?: string }>(
|
||||||
|
'/auth/login',
|
||||||
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ username, password }),
|
body: JSON.stringify({ username, password }),
|
||||||
}),
|
},
|
||||||
|
{ toastOnError: false },
|
||||||
|
),
|
||||||
|
|
||||||
logout: () =>
|
logout: () =>
|
||||||
request<{ success: boolean }>('/auth/logout', { method: 'POST' }),
|
request<{ success: boolean }>('/auth/logout', { method: 'POST' }),
|
||||||
@@ -688,6 +692,7 @@ export {
|
|||||||
type RtspConfigUpdate,
|
type RtspConfigUpdate,
|
||||||
type RtspStatusResponse,
|
type RtspStatusResponse,
|
||||||
type WebConfig,
|
type WebConfig,
|
||||||
|
type WebConfigUpdate,
|
||||||
} from './config'
|
} from './config'
|
||||||
|
|
||||||
// 导出生成的类型
|
// 导出生成的类型
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from '@/components/ui/sheet'
|
||||||
import {
|
import {
|
||||||
ClipboardPaste,
|
ClipboardPaste,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
@@ -74,6 +80,7 @@ const emit = defineEmits<{
|
|||||||
(e: 'openTerminal'): void
|
(e: 'openTerminal'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// Desktop toolbar popover/dialog state
|
||||||
const pasteOpen = ref(false)
|
const pasteOpen = ref(false)
|
||||||
const atxOpen = ref(false)
|
const atxOpen = ref(false)
|
||||||
const videoPopoverOpen = ref(false)
|
const videoPopoverOpen = ref(false)
|
||||||
@@ -81,13 +88,52 @@ const hidPopoverOpen = ref(false)
|
|||||||
const audioPopoverOpen = ref(false)
|
const audioPopoverOpen = ref(false)
|
||||||
const msdDialogOpen = ref(false)
|
const msdDialogOpen = ref(false)
|
||||||
const extensionOpen = ref(false)
|
const extensionOpen = ref(false)
|
||||||
|
|
||||||
|
// Mobile Sheet state — opened from the overflow menu.
|
||||||
|
// We use Sheet (bottom drawer) instead of Popover because Popover relies on an
|
||||||
|
// anchor element that is hidden / clipped on small screens, causing it to
|
||||||
|
// immediately close after opening.
|
||||||
|
const mobileAtxOpen = ref(false)
|
||||||
|
const mobilePasteOpen = ref(false)
|
||||||
|
|
||||||
|
// Timestamps used to suppress spurious "interact-outside" events that arrive
|
||||||
|
// within ~300 ms of the Sheet opening (e.g. delayed synthetic pointer events
|
||||||
|
// from the same touch gesture that opened the overflow menu).
|
||||||
|
const mobileAtxOpenTime = ref(0)
|
||||||
|
const mobilePasteOpenTime = ref(0)
|
||||||
|
|
||||||
|
const OPEN_GUARD_MS = 350
|
||||||
|
|
||||||
|
const guardOutside = (openTime: number, e: Event) => {
|
||||||
|
if (Date.now() - openTime < OPEN_GUARD_MS) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// On mobile, clicking a DropdownMenuItem generates pointer events that can
|
||||||
|
// immediately dismiss any overlay opened in the same tick. Close the dropdown
|
||||||
|
// first, then open the target after a short delay.
|
||||||
|
const openFromOverflow = (setter: () => void) => {
|
||||||
|
overflowMenuOpen.value = false
|
||||||
|
setTimeout(setter, 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
const openMobileAtx = () => openFromOverflow(() => {
|
||||||
|
mobileAtxOpen.value = true
|
||||||
|
mobileAtxOpenTime.value = Date.now()
|
||||||
|
})
|
||||||
|
|
||||||
|
const openMobilePaste = () => openFromOverflow(() => {
|
||||||
|
mobilePasteOpen.value = true
|
||||||
|
mobilePasteOpenTime.value = Date.now()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="w-full border-b border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
|
<div class="w-full border-b border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
|
||||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-2 px-4 py-1.5">
|
<div class="flex items-center px-2 sm:px-4 py-1 sm:py-1.5">
|
||||||
<!-- Left side buttons -->
|
<!-- Left side buttons — overflow hidden so it never pushes into right side -->
|
||||||
<div class="flex flex-wrap items-center gap-1.5 w-full sm:flex-1 sm:min-w-0">
|
<div class="flex items-center gap-0.5 sm:gap-1.5 flex-1 min-w-0 overflow-hidden">
|
||||||
<!-- Video Config - Always visible -->
|
<!-- Video Config - Always visible -->
|
||||||
<VideoConfigPopover
|
<VideoConfigPopover
|
||||||
v-model:open="videoPopoverOpen"
|
v-model:open="videoPopoverOpen"
|
||||||
@@ -95,7 +141,7 @@ const extensionOpen = ref(false)
|
|||||||
@update:video-mode="emit('update:videoMode', $event)"
|
@update:video-mode="emit('update:videoMode', $event)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Audio Config - Always visible -->
|
<!-- Audio Config - Always visible (xs shows icon only) -->
|
||||||
<AudioConfigPopover v-model:open="audioPopoverOpen" />
|
<AudioConfigPopover v-model:open="audioPopoverOpen" />
|
||||||
|
|
||||||
<!-- HID Config - Always visible -->
|
<!-- HID Config - Always visible -->
|
||||||
@@ -105,14 +151,14 @@ const extensionOpen = ref(false)
|
|||||||
@update:mouse-mode="emit('toggleMouseMode')"
|
@update:mouse-mode="emit('toggleMouseMode')"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Virtual Media (MSD) - Hidden on small screens, shown in overflow -->
|
<!-- Virtual Media (MSD) - Hidden below md, shown in overflow -->
|
||||||
<!-- Also hidden when HID backend is CH9329 (no USB gadget support) -->
|
<div v-if="showMsd" class="hidden md:block">
|
||||||
<TooltipProvider v-if="showMsd" class="hidden sm:block">
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="msdDialogOpen = true">
|
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="msdDialogOpen = true">
|
||||||
<HardDrive class="h-4 w-4" />
|
<HardDrive class="h-4 w-4" />
|
||||||
<span class="hidden md:inline">{{ t('actionbar.virtualMedia') }}</span>
|
<span class="hidden lg:inline">{{ t('actionbar.virtualMedia') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -120,16 +166,18 @@ const extensionOpen = ref(false)
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ATX Power Control - Hidden on small screens -->
|
<!-- ATX Power Control - Hidden below md; shown as Sheet on mobile -->
|
||||||
<Popover v-model:open="atxOpen" class="hidden sm:block">
|
<div class="hidden md:block">
|
||||||
|
<Popover v-model:open="atxOpen">
|
||||||
<PopoverTrigger as-child>
|
<PopoverTrigger as-child>
|
||||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
||||||
<Power class="h-4 w-4" />
|
<Power class="h-4 w-4" />
|
||||||
<span class="hidden md:inline">{{ t('actionbar.power') }}</span>
|
<span class="hidden lg:inline">{{ t('actionbar.power') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent class="w-[280px] p-0" align="start">
|
<PopoverContent class="w-[min(280px,90vw)] p-0" align="start">
|
||||||
<AtxPopover
|
<AtxPopover
|
||||||
@close="atxOpen = false"
|
@close="atxOpen = false"
|
||||||
@power-short="emit('powerShort')"
|
@power-short="emit('powerShort')"
|
||||||
@@ -139,29 +187,33 @@ const extensionOpen = ref(false)
|
|||||||
/>
|
/>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Paste Text - Hidden on small screens -->
|
<!-- Paste Text - Hidden below lg; shown as Sheet on mobile -->
|
||||||
<Popover v-model:open="pasteOpen" class="hidden md:block">
|
<div class="hidden lg:block">
|
||||||
|
<Popover v-model:open="pasteOpen">
|
||||||
<PopoverTrigger as-child>
|
<PopoverTrigger as-child>
|
||||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
||||||
<ClipboardPaste class="h-4 w-4" />
|
<ClipboardPaste class="h-4 w-4" />
|
||||||
<span class="hidden lg:inline">{{ t('actionbar.paste') }}</span>
|
<span class="hidden xl:inline">{{ t('actionbar.paste') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent class="w-[400px] p-0" align="start">
|
<PopoverContent class="w-[min(400px,90vw)] p-0" align="start">
|
||||||
<PasteModal @close="pasteOpen = false" />
|
<PasteModal @close="pasteOpen = false" />
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Right side buttons -->
|
<!-- Right side buttons — always shrink-0, never compressed -->
|
||||||
<div class="flex items-center gap-1.5 w-full justify-end sm:w-auto sm:ml-auto shrink-0">
|
<div class="flex items-center gap-0.5 sm:gap-1.5 shrink-0 ml-1 sm:ml-2">
|
||||||
<!-- Extension Menu - Hidden on small screens -->
|
<!-- Extension Menu - Hidden below xl -->
|
||||||
<Popover v-model:open="extensionOpen" class="hidden lg:block">
|
<div class="hidden xl:block">
|
||||||
|
<Popover v-model:open="extensionOpen">
|
||||||
<PopoverTrigger as-child>
|
<PopoverTrigger as-child>
|
||||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
||||||
<Cable class="h-4 w-4" />
|
<Cable class="h-4 w-4" />
|
||||||
<span class="hidden xl:inline">{{ t('actionbar.extension') }}</span>
|
{{ t('actionbar.extension') }}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent class="w-48 p-1" align="start">
|
<PopoverContent class="w-48 p-1" align="start">
|
||||||
@@ -179,14 +231,16 @@ const extensionOpen = ref(false)
|
|||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Settings - Hidden on small screens -->
|
<!-- Settings - Hidden below xl -->
|
||||||
<TooltipProvider class="hidden lg:block">
|
<div class="hidden xl:block">
|
||||||
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="router.push('/settings')">
|
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="router.push('/settings')">
|
||||||
<Settings class="h-4 w-4" />
|
<Settings class="h-4 w-4" />
|
||||||
<span class="hidden xl:inline">{{ t('actionbar.settings') }}</span>
|
{{ t('actionbar.settings') }}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -194,9 +248,11 @@ const extensionOpen = ref(false)
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Connection Stats - Hidden on very small screens -->
|
<!-- Connection Stats - Hidden below md -->
|
||||||
<TooltipProvider class="hidden sm:block">
|
<div class="hidden md:block">
|
||||||
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="emit('toggleStats')">
|
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs" @click="emit('toggleStats')">
|
||||||
@@ -209,8 +265,9 @@ const extensionOpen = ref(false)
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="h-5 w-px bg-slate-200 dark:bg-slate-700 hidden sm:block" />
|
<div class="h-5 w-px bg-slate-200 dark:bg-slate-700 hidden md:block" />
|
||||||
|
|
||||||
<!-- Virtual Keyboard - Always visible (important for mobile) -->
|
<!-- Virtual Keyboard - Always visible (important for mobile) -->
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
@@ -219,10 +276,10 @@ const extensionOpen = ref(false)
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-8 gap-1.5 text-xs"
|
class="h-7 w-7 sm:h-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs"
|
||||||
@click="emit('toggleVirtualKeyboard')"
|
@click="emit('toggleVirtualKeyboard')"
|
||||||
>
|
>
|
||||||
<Keyboard class="h-4 w-4" />
|
<Keyboard class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||||
<span class="hidden xl:inline">{{ t('actionbar.keyboard') }}</span>
|
<span class="hidden xl:inline">{{ t('actionbar.keyboard') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -239,10 +296,10 @@ const extensionOpen = ref(false)
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="h-8 gap-1.5 text-xs"
|
class="h-7 w-7 sm:h-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs"
|
||||||
@click="emit('toggleFullscreen')"
|
@click="emit('toggleFullscreen')"
|
||||||
>
|
>
|
||||||
<Maximize class="h-4 w-4" />
|
<Maximize class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||||
<span class="hidden xl:inline">{{ t('actionbar.fullscreen') }}</span>
|
<span class="hidden xl:inline">{{ t('actionbar.fullscreen') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -252,52 +309,52 @@ const extensionOpen = ref(false)
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
||||||
<!-- Overflow Menu - Shows hidden items on small screens -->
|
<!-- Overflow Menu - Shows hidden items on smaller screens -->
|
||||||
<DropdownMenu v-model:open="overflowMenuOpen">
|
<DropdownMenu v-model:open="overflowMenuOpen">
|
||||||
<DropdownMenuTrigger as-child>
|
<DropdownMenuTrigger as-child>
|
||||||
<Button variant="ghost" size="sm" class="h-8 w-8 p-0 lg:hidden">
|
<Button variant="ghost" size="sm" class="h-7 w-7 sm:h-8 sm:w-8 p-0 xl:hidden">
|
||||||
<MoreHorizontal class="h-4 w-4" />
|
<MoreHorizontal class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" class="w-48">
|
<DropdownMenuContent align="end" class="w-48">
|
||||||
<!-- MSD - Mobile only, hidden when CH9329 backend -->
|
<!-- MSD - Below md, hidden when CH9329 backend -->
|
||||||
<DropdownMenuItem v-if="showMsd" class="sm:hidden" @click="msdDialogOpen = true; overflowMenuOpen = false">
|
<DropdownMenuItem v-if="showMsd" class="md:hidden" @click="openFromOverflow(() => msdDialogOpen = true)">
|
||||||
<HardDrive class="h-4 w-4 mr-2" />
|
<HardDrive class="h-4 w-4 mr-2" />
|
||||||
{{ t('actionbar.virtualMedia') }}
|
{{ t('actionbar.virtualMedia') }}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<!-- ATX - Mobile only -->
|
<!-- ATX - Opens a Sheet on mobile (below md) -->
|
||||||
<DropdownMenuItem class="sm:hidden" @click="atxOpen = true; overflowMenuOpen = false">
|
<DropdownMenuItem class="md:hidden" @click="openMobileAtx">
|
||||||
<Power class="h-4 w-4 mr-2" />
|
<Power class="h-4 w-4 mr-2" />
|
||||||
{{ t('actionbar.power') }}
|
{{ t('actionbar.power') }}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<!-- Paste - Tablet and below -->
|
<!-- Paste - Opens a Sheet on mobile (below lg) -->
|
||||||
<DropdownMenuItem class="md:hidden" @click="pasteOpen = true; overflowMenuOpen = false">
|
<DropdownMenuItem class="lg:hidden" @click="openMobilePaste">
|
||||||
<ClipboardPaste class="h-4 w-4 mr-2" />
|
<ClipboardPaste class="h-4 w-4 mr-2" />
|
||||||
{{ t('actionbar.paste') }}
|
{{ t('actionbar.paste') }}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuSeparator class="lg:hidden" />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
<!-- Stats - Mobile only -->
|
<!-- Stats - Below md -->
|
||||||
<DropdownMenuItem class="sm:hidden" @click="emit('toggleStats'); overflowMenuOpen = false">
|
<DropdownMenuItem class="md:hidden" @click="openFromOverflow(() => emit('toggleStats'))">
|
||||||
<BarChart3 class="h-4 w-4 mr-2" />
|
<BarChart3 class="h-4 w-4 mr-2" />
|
||||||
{{ t('actionbar.stats') }}
|
{{ t('actionbar.stats') }}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<!-- Extension - Tablet and below -->
|
<!-- Extension - Below xl -->
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
class="lg:hidden"
|
class="xl:hidden"
|
||||||
:disabled="!props.ttydRunning"
|
:disabled="!props.ttydRunning"
|
||||||
@click="emit('openTerminal'); overflowMenuOpen = false"
|
@click="openFromOverflow(() => emit('openTerminal'))"
|
||||||
>
|
>
|
||||||
<Terminal class="h-4 w-4 mr-2" />
|
<Terminal class="h-4 w-4 mr-2" />
|
||||||
{{ t('extensions.ttyd.title') }}
|
{{ t('extensions.ttyd.title') }}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<!-- Settings - Tablet and below -->
|
<!-- Settings - Below xl -->
|
||||||
<DropdownMenuItem class="lg:hidden" @click="router.push('/settings'); overflowMenuOpen = false">
|
<DropdownMenuItem class="xl:hidden" @click="openFromOverflow(() => router.push('/settings'))">
|
||||||
<Settings class="h-4 w-4 mr-2" />
|
<Settings class="h-4 w-4 mr-2" />
|
||||||
{{ t('actionbar.settings') }}
|
{{ t('actionbar.settings') }}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -309,4 +366,41 @@ const extensionOpen = ref(false)
|
|||||||
|
|
||||||
<!-- MSD Dialog -->
|
<!-- MSD Dialog -->
|
||||||
<MsdDialog v-if="showMsd" v-model:open="msdDialogOpen" />
|
<MsdDialog v-if="showMsd" v-model:open="msdDialogOpen" />
|
||||||
|
|
||||||
|
<!-- Mobile ATX Sheet — used when ATX is opened from the overflow menu.
|
||||||
|
A Sheet avoids the Popover anchor-positioning issues on mobile. -->
|
||||||
|
<Sheet v-model:open="mobileAtxOpen">
|
||||||
|
<SheetContent
|
||||||
|
side="bottom"
|
||||||
|
class="max-h-[90dvh] overflow-y-auto"
|
||||||
|
@pointer-down-outside="(e) => guardOutside(mobileAtxOpenTime, e)"
|
||||||
|
@interact-outside="(e) => guardOutside(mobileAtxOpenTime, e)"
|
||||||
|
>
|
||||||
|
<SheetHeader class="mb-2">
|
||||||
|
<SheetTitle>{{ t('actionbar.power') }}</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<AtxPopover
|
||||||
|
@close="mobileAtxOpen = false"
|
||||||
|
@power-short="emit('powerShort')"
|
||||||
|
@power-long="emit('powerLong')"
|
||||||
|
@reset="emit('reset')"
|
||||||
|
@wol="(mac) => emit('wol', mac)"
|
||||||
|
/>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
|
||||||
|
<!-- Mobile Paste Sheet — used when Paste is opened from the overflow menu. -->
|
||||||
|
<Sheet v-model:open="mobilePasteOpen">
|
||||||
|
<SheetContent
|
||||||
|
side="bottom"
|
||||||
|
class="max-h-[90dvh] overflow-y-auto"
|
||||||
|
@pointer-down-outside="(e) => guardOutside(mobilePasteOpenTime, e)"
|
||||||
|
@interact-outside="(e) => guardOutside(mobilePasteOpenTime, e)"
|
||||||
|
>
|
||||||
|
<SheetHeader class="mb-2">
|
||||||
|
<SheetTitle>{{ t('actionbar.paste') }}</SheetTitle>
|
||||||
|
</SheetHeader>
|
||||||
|
<PasteModal @close="mobilePasteOpen = false" />
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,50 +1,35 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { RouterLink, useRouter } from 'vue-router'
|
||||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useSystemStore } from '@/stores/system'
|
import { useSystemStore } from '@/stores/system'
|
||||||
|
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
|
||||||
|
import BrandMark from '@/components/BrandMark.vue'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import {
|
import {
|
||||||
Monitor,
|
|
||||||
Settings,
|
|
||||||
LogOut,
|
LogOut,
|
||||||
Sun,
|
Sun,
|
||||||
Moon,
|
Moon,
|
||||||
Languages,
|
|
||||||
Menu,
|
Menu,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { setLanguage } from '@/i18n'
|
|
||||||
|
|
||||||
const { t, locale } = useI18n()
|
const { t } = useI18n()
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const systemStore = useSystemStore()
|
const systemStore = useSystemStore()
|
||||||
|
|
||||||
const navItems = computed(() => [
|
|
||||||
{ path: '/', name: 'Console', icon: Monitor, label: t('nav.console') },
|
|
||||||
{ path: '/settings', name: 'Settings', icon: Settings, label: t('nav.settings') },
|
|
||||||
])
|
|
||||||
|
|
||||||
function toggleTheme() {
|
function toggleTheme() {
|
||||||
const isDark = document.documentElement.classList.contains('dark')
|
const isDark = document.documentElement.classList.contains('dark')
|
||||||
document.documentElement.classList.toggle('dark', !isDark)
|
document.documentElement.classList.toggle('dark', !isDark)
|
||||||
localStorage.setItem('theme', isDark ? 'light' : 'dark')
|
localStorage.setItem('theme', isDark ? 'light' : 'dark')
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleLanguage() {
|
|
||||||
const newLang = locale.value === 'zh-CN' ? 'en-US' : 'zh-CN'
|
|
||||||
setLanguage(newLang)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await authStore.logout()
|
await authStore.logout()
|
||||||
router.push('/login')
|
router.push('/login')
|
||||||
@@ -55,62 +40,38 @@ async function handleLogout() {
|
|||||||
<div class="h-screen h-dvh flex flex-col bg-background overflow-hidden">
|
<div class="h-screen h-dvh flex flex-col bg-background overflow-hidden">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="shrink-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
<header class="shrink-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||||
<div class="flex h-14 items-center px-4 max-w-full">
|
<div class="flex h-11 sm:h-14 items-center px-3 sm:px-4 max-w-full">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<RouterLink to="/" class="flex items-center gap-2 font-semibold">
|
<RouterLink to="/" class="flex items-center gap-1.5 sm:gap-2 font-semibold">
|
||||||
<Monitor class="h-5 w-5" />
|
<BrandMark size="sm" />
|
||||||
<span class="hidden sm:inline">One-KVM</span>
|
<span class="hidden sm:inline">One-KVM</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<!-- Navigation -->
|
|
||||||
<nav class="hidden md:flex items-center gap-1 ml-6">
|
|
||||||
<RouterLink
|
|
||||||
v-for="item in navItems"
|
|
||||||
:key="item.path"
|
|
||||||
:to="item.path"
|
|
||||||
class="flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-md transition-colors"
|
|
||||||
:class="route.path === item.path
|
|
||||||
? 'bg-accent text-accent-foreground'
|
|
||||||
: 'text-muted-foreground hover:text-foreground hover:bg-accent/50'"
|
|
||||||
>
|
|
||||||
<component :is="item.icon" class="h-4 w-4" />
|
|
||||||
{{ item.label }}
|
|
||||||
</RouterLink>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<!-- Right Side -->
|
<!-- Right Side -->
|
||||||
<div class="flex items-center gap-2 ml-auto">
|
<div class="flex items-center gap-1 sm:gap-2 ml-auto">
|
||||||
<!-- Version Badge -->
|
<!-- Version Badge -->
|
||||||
<span v-if="systemStore.version" class="hidden sm:inline text-xs text-muted-foreground">
|
<span v-if="systemStore.version" class="hidden sm:inline text-xs text-muted-foreground">
|
||||||
v{{ systemStore.version }}
|
v{{ systemStore.version }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- Theme Toggle -->
|
<!-- Theme Toggle -->
|
||||||
<Button variant="ghost" size="icon" :aria-label="t('common.toggleTheme')" @click="toggleTheme">
|
<Button variant="ghost" size="icon" class="h-8 w-8" :aria-label="t('common.toggleTheme')" @click="toggleTheme">
|
||||||
<Sun class="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
<Sun class="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
<Moon class="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
<Moon class="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
<span class="sr-only">{{ t('common.toggleTheme') }}</span>
|
<span class="sr-only">{{ t('common.toggleTheme') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<!-- Language Toggle -->
|
<!-- Language Toggle -->
|
||||||
<Button variant="ghost" size="icon" :aria-label="t('common.toggleLanguage')" @click="toggleLanguage">
|
<LanguageToggleButton />
|
||||||
<Languages class="h-4 w-4" />
|
|
||||||
<span class="sr-only">{{ t('common.toggleLanguage') }}</span>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<!-- Mobile Menu -->
|
<!-- Mobile Menu -->
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger as-child class="md:hidden">
|
<DropdownMenuTrigger as-child class="md:hidden">
|
||||||
<Button variant="ghost" size="icon" :aria-label="t('common.menu')">
|
<Button variant="ghost" size="icon" class="h-8 w-8" :aria-label="t('common.menu')">
|
||||||
<Menu class="h-4 w-4" />
|
<Menu class="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem v-for="item in navItems" :key="item.path" @click="router.push(item.path)">
|
|
||||||
<component :is="item.icon" class="h-4 w-4 mr-2" />
|
|
||||||
{{ item.label }}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem @click="handleLogout">
|
<DropdownMenuItem @click="handleLogout">
|
||||||
<LogOut class="h-4 w-4 mr-2" />
|
<LogOut class="h-4 w-4 mr-2" />
|
||||||
{{ t('nav.logout') }}
|
{{ t('nav.logout') }}
|
||||||
@@ -119,7 +80,7 @@ async function handleLogout() {
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
<!-- Logout Button (Desktop) -->
|
<!-- Logout Button (Desktop) -->
|
||||||
<Button variant="ghost" size="icon" class="hidden md:flex" :aria-label="t('nav.logout')" @click="handleLogout">
|
<Button variant="ghost" size="icon" class="hidden md:flex h-8 w-8" :aria-label="t('nav.logout')" @click="handleLogout">
|
||||||
<LogOut class="h-4 w-4" />
|
<LogOut class="h-4 w-4" />
|
||||||
<span class="sr-only">{{ t('nav.logout') }}</span>
|
<span class="sr-only">{{ t('nav.logout') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -169,12 +169,12 @@ watch(() => props.open, (isOpen) => {
|
|||||||
<template>
|
<template>
|
||||||
<Popover :open="open" @update:open="emit('update:open', $event)">
|
<Popover :open="open" @update:open="emit('update:open', $event)">
|
||||||
<PopoverTrigger as-child>
|
<PopoverTrigger as-child>
|
||||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
<Button variant="ghost" size="sm" class="h-7 w-7 sm:h-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs">
|
||||||
<Volume2 class="h-4 w-4" />
|
<Volume2 class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||||
<span class="hidden sm:inline">{{ t('actionbar.audioConfig') }}</span>
|
<span class="hidden sm:inline">{{ t('actionbar.audioConfig') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent class="w-[320px] p-3" align="start">
|
<PopoverContent class="w-[min(320px,92vw)] p-3" align="start">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<h4 class="text-sm font-medium">{{ t('actionbar.audioConfig') }}</h4>
|
<h4 class="text-sm font-medium">{{ t('actionbar.audioConfig') }}</h4>
|
||||||
|
|
||||||
|
|||||||
39
web/src/components/BrandMark.vue
Normal file
39
web/src/components/BrandMark.vue
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||||
|
class?: string
|
||||||
|
}>(),
|
||||||
|
{ size: 'md' },
|
||||||
|
)
|
||||||
|
|
||||||
|
const dim = computed(() => {
|
||||||
|
switch (props.size) {
|
||||||
|
case 'xs':
|
||||||
|
return 'h-4 w-4'
|
||||||
|
case 'sm':
|
||||||
|
return 'h-5 w-5'
|
||||||
|
case 'md':
|
||||||
|
return 'h-6 w-6'
|
||||||
|
case 'lg':
|
||||||
|
return 'h-10 w-10'
|
||||||
|
case 'xl':
|
||||||
|
return 'h-14 w-14'
|
||||||
|
default:
|
||||||
|
return 'h-6 w-6'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<img
|
||||||
|
src="/favicon.png"
|
||||||
|
alt=""
|
||||||
|
:class="cn('shrink-0 object-contain select-none', dim, props.class)"
|
||||||
|
decoding="async"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -247,13 +247,13 @@ watch(() => props.open, (isOpen) => {
|
|||||||
<template>
|
<template>
|
||||||
<Popover :open="open" @update:open="emit('update:open', $event)">
|
<Popover :open="open" @update:open="emit('update:open', $event)">
|
||||||
<PopoverTrigger as-child>
|
<PopoverTrigger as-child>
|
||||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
<Button variant="ghost" size="sm" class="h-7 w-7 sm:h-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs">
|
||||||
<MousePointer v-if="mouseMode === 'absolute'" class="h-4 w-4" />
|
<MousePointer v-if="mouseMode === 'absolute'" class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||||
<Move v-else class="h-4 w-4" />
|
<Move v-else class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||||
<span class="hidden sm:inline">{{ buttonText }}</span>
|
<span class="hidden sm:inline">{{ buttonText }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent class="w-[320px] p-3" align="start">
|
<PopoverContent class="w-[min(320px,92vw)] p-3" align="start">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<h4 class="text-sm font-medium">{{ t('actionbar.hidConfig') }}</h4>
|
<h4 class="text-sm font-medium">{{ t('actionbar.hidConfig') }}</h4>
|
||||||
|
|
||||||
|
|||||||
@@ -42,15 +42,13 @@ const keysDisplay = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="w-full border-t border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
|
<div class="w-full border-t border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
|
||||||
<!-- Compact mode for small screens -->
|
<!-- Compact mode (explicit prop or auto on small screens via sm:hidden) -->
|
||||||
<div v-if="compact" class="flex items-center justify-between text-xs px-2 py-0.5">
|
<div :class="compact ? '' : 'sm:hidden'">
|
||||||
<!-- LED indicator only in compact mode -->
|
<div class="flex items-center justify-between text-xs px-2 py-0.5">
|
||||||
<div v-if="keyboardLedEnabled" class="flex items-center gap-1">
|
<div v-if="keyboardLedEnabled" class="flex items-center gap-1">
|
||||||
<span
|
<span
|
||||||
v-if="capsLock"
|
:class="capsLock ? 'px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium' : 'text-muted-foreground/40 text-[10px]'"
|
||||||
class="px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium"
|
|
||||||
>C</span>
|
>C</span>
|
||||||
<span v-else class="text-muted-foreground/40 text-[10px]">C</span>
|
|
||||||
<span
|
<span
|
||||||
:class="numLock ? 'px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium' : 'text-muted-foreground/40 text-[10px]'"
|
:class="numLock ? 'px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px] font-medium' : 'text-muted-foreground/40 text-[10px]'"
|
||||||
>N</span>
|
>N</span>
|
||||||
@@ -61,23 +59,22 @@ const keysDisplay = computed(() => {
|
|||||||
<div v-else class="text-[10px] text-muted-foreground/60">
|
<div v-else class="text-[10px] text-muted-foreground/60">
|
||||||
{{ t('infobar.keyboardLedUnavailable') }}
|
{{ t('infobar.keyboardLedUnavailable') }}
|
||||||
</div>
|
</div>
|
||||||
<!-- Keys in compact mode -->
|
<div v-if="keysDisplay" class="text-[10px] text-muted-foreground truncate max-w-[200px]">
|
||||||
<div v-if="keysDisplay" class="text-[10px] text-muted-foreground truncate max-w-[150px]">
|
|
||||||
{{ keysDisplay }}
|
{{ keysDisplay }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Normal mode -->
|
<!-- Normal mode (hidden on small screens unless compact is explicitly set) -->
|
||||||
<div v-else class="flex flex-wrap items-center justify-between text-xs">
|
<div :class="compact ? 'hidden' : 'hidden sm:block'">
|
||||||
|
<div class="flex flex-wrap items-center justify-between text-xs">
|
||||||
<!-- Left side: Debug info and pressed keys -->
|
<!-- Left side: Debug info and pressed keys -->
|
||||||
<div class="flex items-center gap-4 px-3 py-1 min-w-0 flex-1">
|
<div class="flex items-center gap-4 px-3 py-1 min-w-0 flex-1">
|
||||||
<!-- Pressed Keys -->
|
|
||||||
<div class="flex items-center gap-1.5 min-w-0">
|
<div class="flex items-center gap-1.5 min-w-0">
|
||||||
<span class="font-medium text-muted-foreground shrink-0 hidden sm:inline">{{ t('infobar.keys') }}:</span>
|
<span class="font-medium text-muted-foreground shrink-0">{{ t('infobar.keys') }}:</span>
|
||||||
<span class="text-foreground truncate">{{ keysDisplay || '-' }}</span>
|
<span class="text-foreground truncate">{{ keysDisplay || '-' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Debug: Mouse Position -->
|
|
||||||
<div v-if="debugMode && mousePosition" class="flex items-center gap-1.5 hidden md:flex">
|
<div v-if="debugMode && mousePosition" class="flex items-center gap-1.5 hidden md:flex">
|
||||||
<span class="font-medium text-muted-foreground">{{ t('infobar.pointer') }}:</span>
|
<span class="font-medium text-muted-foreground">{{ t('infobar.pointer') }}:</span>
|
||||||
<span class="text-foreground">{{ mousePosition.x }}, {{ mousePosition.y }}</span>
|
<span class="text-foreground">{{ mousePosition.x }}, {{ mousePosition.y }}</span>
|
||||||
@@ -92,28 +89,19 @@ const keysDisplay = computed(() => {
|
|||||||
'px-2 py-1 select-none transition-colors',
|
'px-2 py-1 select-none transition-colors',
|
||||||
capsLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
|
capsLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
|
||||||
)"
|
)"
|
||||||
>
|
>{{ t('infobar.caps') }}</div>
|
||||||
<span class="hidden sm:inline">{{ t('infobar.caps') }}</span>
|
|
||||||
<span class="sm:hidden">C</span>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
:class="cn(
|
:class="cn(
|
||||||
'px-2 py-1 select-none transition-colors',
|
'px-2 py-1 select-none transition-colors',
|
||||||
numLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
|
numLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
|
||||||
)"
|
)"
|
||||||
>
|
>{{ t('infobar.num') }}</div>
|
||||||
<span class="hidden sm:inline">{{ t('infobar.num') }}</span>
|
|
||||||
<span class="sm:hidden">N</span>
|
|
||||||
</div>
|
|
||||||
<div
|
<div
|
||||||
:class="cn(
|
:class="cn(
|
||||||
'px-2 py-1 select-none transition-colors',
|
'px-2 py-1 select-none transition-colors',
|
||||||
scrollLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
|
scrollLock ? 'text-foreground font-medium bg-primary/5' : 'text-muted-foreground/40'
|
||||||
)"
|
)"
|
||||||
>
|
>{{ t('infobar.scroll') }}</div>
|
||||||
<span class="hidden sm:inline">{{ t('infobar.scroll') }}</span>
|
|
||||||
<span class="sm:hidden">S</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="px-3 py-1 text-muted-foreground/60">
|
<div v-else class="px-3 py-1 text-muted-foreground/60">
|
||||||
{{ t('infobar.keyboardLedUnavailable') }}
|
{{ t('infobar.keyboardLedUnavailable') }}
|
||||||
@@ -121,4 +109,5 @@ const keysDisplay = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
50
web/src/components/LanguageToggleButton.vue
Normal file
50
web/src/components/LanguageToggleButton.vue
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import type { HTMLAttributes } from 'vue'
|
||||||
|
import type { ButtonVariants } from '@/components/ui/button'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { setLanguage } from '@/i18n'
|
||||||
|
import { Languages } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
class?: HTMLAttributes['class']
|
||||||
|
size?: ButtonVariants['size']
|
||||||
|
variant?: ButtonVariants['variant']
|
||||||
|
labelMode?: 'hidden' | 'current' | 'next'
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
size: 'icon',
|
||||||
|
variant: 'ghost',
|
||||||
|
labelMode: 'hidden',
|
||||||
|
})
|
||||||
|
|
||||||
|
const { t, locale } = useI18n()
|
||||||
|
|
||||||
|
const currentLanguageLabel = computed(() => (locale.value === 'zh-CN' ? '中文' : 'English'))
|
||||||
|
const nextLanguageLabel = computed(() => (locale.value === 'zh-CN' ? 'English' : '中文'))
|
||||||
|
const buttonLabel = computed(() => (
|
||||||
|
props.labelMode === 'current' ? currentLanguageLabel.value : nextLanguageLabel.value
|
||||||
|
))
|
||||||
|
|
||||||
|
function toggleLanguage() {
|
||||||
|
const newLang = locale.value === 'zh-CN' ? 'en-US' : 'zh-CN'
|
||||||
|
setLanguage(newLang)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Button
|
||||||
|
:variant="variant"
|
||||||
|
:size="size"
|
||||||
|
:class="cn(props.labelMode !== 'hidden' && 'gap-2', props.class)"
|
||||||
|
:aria-label="t('common.toggleLanguage')"
|
||||||
|
@click="toggleLanguage"
|
||||||
|
>
|
||||||
|
<Languages class="h-4 w-4" />
|
||||||
|
<span v-if="props.labelMode !== 'hidden'">{{ buttonLabel }}</span>
|
||||||
|
<span class="sr-only">{{ t('common.toggleLanguage') }}</span>
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
@@ -28,7 +28,6 @@ import { Label } from '@/components/ui/label'
|
|||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
|
||||||
import {
|
import {
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Upload,
|
Upload,
|
||||||
@@ -512,7 +511,7 @@ onUnmounted(() => {
|
|||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Dialog :open="open" @update:open="emit('update:open', $event)">
|
<Dialog :open="open" @update:open="emit('update:open', $event)">
|
||||||
<DialogContent class="sm:max-w-[600px] max-h-[90vh] overflow-hidden flex flex-col p-0">
|
<DialogContent class="sm:max-w-[600px] max-h-[90vh] overflow-hidden flex flex-col p-0">
|
||||||
<DialogHeader class="px-6 pt-6">
|
<DialogHeader class="px-6 pt-6 shrink-0">
|
||||||
<DialogTitle class="flex items-center gap-2">
|
<DialogTitle class="flex items-center gap-2">
|
||||||
<HardDrive class="h-5 w-5" />
|
<HardDrive class="h-5 w-5" />
|
||||||
{{ t('msd.title') }}
|
{{ t('msd.title') }}
|
||||||
@@ -551,10 +550,11 @@ onUnmounted(() => {
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<Separator />
|
<Separator class="shrink-0" />
|
||||||
|
|
||||||
<Tabs v-model="activeTab" class="flex-1 flex flex-col overflow-hidden px-6 pb-6 pt-4">
|
<div class="flex-1 min-h-0 flex flex-col px-6 pb-6 pt-4">
|
||||||
<TabsList class="w-full grid grid-cols-2">
|
<Tabs v-model="activeTab" class="flex-1 flex flex-col min-h-0">
|
||||||
|
<TabsList class="w-full grid grid-cols-2 shrink-0">
|
||||||
<TabsTrigger value="images">
|
<TabsTrigger value="images">
|
||||||
<Disc class="h-4 w-4 mr-1.5" />
|
<Disc class="h-4 w-4 mr-1.5" />
|
||||||
{{ t('msd.images') }}
|
{{ t('msd.images') }}
|
||||||
@@ -566,15 +566,13 @@ onUnmounted(() => {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<!-- Tab Description -->
|
<!-- Tab Description -->
|
||||||
<p class="text-xs text-muted-foreground mt-2 mb-1">
|
<p class="text-xs text-muted-foreground mt-2 mb-1 shrink-0">
|
||||||
{{ activeTab === 'images' ? t('msd.imagesDesc') : t('msd.driveDesc') }}
|
{{ activeTab === 'images' ? t('msd.imagesDesc') : t('msd.driveDesc') }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ScrollArea class="flex-1 mt-2">
|
<TabsContent value="images" class="flex-1 min-h-0 m-0 flex flex-col space-y-3">
|
||||||
<!-- Images Tab -->
|
|
||||||
<TabsContent value="images" class="m-0 space-y-3 pr-4">
|
|
||||||
<!-- Compact Upload Toolbar -->
|
<!-- Compact Upload Toolbar -->
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<div class="shrink-0 flex items-center gap-2 min-w-0">
|
||||||
<label class="flex-1">
|
<label class="flex-1">
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
@@ -598,10 +596,10 @@ onUnmounted(() => {
|
|||||||
{{ t('msd.downloadFromUrl') }}
|
{{ t('msd.downloadFromUrl') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Progress v-if="uploading" :model-value="uploadProgress" class="h-1" />
|
<Progress v-if="uploading" :model-value="uploadProgress" class="h-1 shrink-0" />
|
||||||
|
|
||||||
<!-- Options - Vertical compact layout -->
|
<!-- Options - Vertical compact layout -->
|
||||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 p-2 rounded-lg bg-muted/50 text-xs min-w-0">
|
<div class="shrink-0 flex flex-wrap items-center gap-x-4 gap-y-2 p-2 rounded-lg bg-muted/50 text-xs min-w-0">
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<span class="text-muted-foreground whitespace-nowrap">{{ t('msd.storageMode') }}:</span>
|
<span class="text-muted-foreground whitespace-nowrap">{{ t('msd.storageMode') }}:</span>
|
||||||
<HelpTooltip :content="mountMode === 'flash' ? t('help.flashMode') : t('help.cdromMode')" icon-size="sm" />
|
<HelpTooltip :content="mountMode === 'flash' ? t('help.flashMode') : t('help.cdromMode')" icon-size="sm" />
|
||||||
@@ -629,19 +627,20 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Image List -->
|
<!-- Image List -->
|
||||||
<div class="space-y-2 min-w-0">
|
<div class="flex-1 min-h-0 flex flex-col space-y-2 min-w-0">
|
||||||
<div class="flex items-center justify-between">
|
<div class="shrink-0 flex items-center justify-between">
|
||||||
<h4 class="text-sm font-medium">{{ t('msd.imageList') }}</h4>
|
<h4 class="text-sm font-medium">{{ t('msd.imageList') }}</h4>
|
||||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="loadImages">
|
<Button variant="ghost" size="icon" class="h-7 w-7" @click="loadImages">
|
||||||
<RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': loadingImages }" />
|
<RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': loadingImages }" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="images.length === 0" class="text-center py-6 text-muted-foreground text-sm">
|
<div v-if="images.length === 0" class="shrink-0 text-center py-6 text-muted-foreground text-sm">
|
||||||
{{ t('msd.noImages') }}
|
{{ t('msd.noImages') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-2">
|
<div v-else class="flex-1 min-h-0 overflow-y-auto pr-2 custom-scrollbar">
|
||||||
|
<div class="space-y-2">
|
||||||
<div
|
<div
|
||||||
v-for="image in images"
|
v-for="image in images"
|
||||||
:key="image.id"
|
:key="image.id"
|
||||||
@@ -719,9 +718,10 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- System Storage Footer -->
|
<!-- System Storage Footer -->
|
||||||
<div v-if="systemStore.diskSpace" class="pt-2 border-t mt-2">
|
<div v-if="systemStore.diskSpace" class="shrink-0 pt-2 border-t mt-2">
|
||||||
<p class="text-[11px] text-muted-foreground text-center">
|
<p class="text-[11px] text-muted-foreground text-center">
|
||||||
{{ t('msd.systemAvailable') }}: {{ formatBytes(systemStore.diskSpace.available) }}
|
{{ t('msd.systemAvailable') }}: {{ formatBytes(systemStore.diskSpace.available) }}
|
||||||
</p>
|
</p>
|
||||||
@@ -729,10 +729,9 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<!-- Drive Tab -->
|
<TabsContent value="drive" class="flex-1 min-h-0 m-0 flex flex-col space-y-4">
|
||||||
<TabsContent value="drive" class="m-0 space-y-4 pr-4">
|
|
||||||
<template v-if="!driveInitialized">
|
<template v-if="!driveInitialized">
|
||||||
<div class="text-center py-8 space-y-4">
|
<div class="shrink-0 text-center py-8 space-y-4">
|
||||||
<HardDrive class="h-10 w-10 mx-auto text-muted-foreground" />
|
<HardDrive class="h-10 w-10 mx-auto text-muted-foreground" />
|
||||||
<p class="text-sm text-muted-foreground">{{ t('msd.driveNotInitialized') }}</p>
|
<p class="text-sm text-muted-foreground">{{ t('msd.driveNotInitialized') }}</p>
|
||||||
<Button size="sm" @click="initializeDrive">
|
<Button size="sm" @click="initializeDrive">
|
||||||
@@ -743,7 +742,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Drive Info Card -->
|
<!-- Drive Info Card -->
|
||||||
<div class="p-3 rounded-lg border space-y-3" :class="msdConnected && msdMode === 'drive' ? 'border-primary bg-primary/5' : 'bg-muted/50'">
|
<div class="shrink-0 p-3 rounded-lg border space-y-3" :class="msdConnected && msdMode === 'drive' ? 'border-primary bg-primary/5' : 'bg-muted/50'">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<HardDrive class="h-4 w-4 text-muted-foreground" />
|
<HardDrive class="h-4 w-4 text-muted-foreground" />
|
||||||
@@ -801,9 +800,9 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- File Browser -->
|
<!-- File Browser -->
|
||||||
<div class="space-y-2">
|
<div class="flex-1 min-h-0 flex flex-col space-y-2">
|
||||||
<!-- Toolbar -->
|
<!-- Toolbar -->
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="shrink-0 flex items-center justify-between gap-2">
|
||||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||||
<Button
|
<Button
|
||||||
v-if="currentPath !== '/'"
|
v-if="currentPath !== '/'"
|
||||||
@@ -827,7 +826,7 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1 shrink-0">
|
<div class="shrink-0 flex items-center gap-1 shrink-0">
|
||||||
<label>
|
<label>
|
||||||
<input type="file" class="hidden" :disabled="uploadingFile" @change="handleFileUpload" />
|
<input type="file" class="hidden" :disabled="uploadingFile" @change="handleFileUpload" />
|
||||||
<Button variant="ghost" size="icon" as="span" class="h-7 w-7 cursor-pointer">
|
<Button variant="ghost" size="icon" as="span" class="h-7 w-7 cursor-pointer">
|
||||||
@@ -843,14 +842,15 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Progress v-if="uploadingFile" :model-value="fileUploadProgress" class="h-1" />
|
<Progress v-if="uploadingFile" :model-value="fileUploadProgress" class="h-1 shrink-0" />
|
||||||
|
|
||||||
<!-- File List -->
|
<!-- File List -->
|
||||||
<div v-if="driveFiles.length === 0" class="text-center py-6 text-muted-foreground text-sm">
|
<div v-if="driveFiles.length === 0" class="shrink-0 text-center py-6 text-muted-foreground text-sm">
|
||||||
{{ t('msd.emptyFolder') }}
|
{{ t('msd.emptyFolder') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-1">
|
<div v-else class="flex-1 min-h-0 overflow-y-auto pr-2 custom-scrollbar">
|
||||||
|
<div class="space-y-1">
|
||||||
<div
|
<div
|
||||||
v-for="file in driveFiles"
|
v-for="file in driveFiles"
|
||||||
:key="file.path"
|
:key="file.path"
|
||||||
@@ -900,10 +900,11 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</ScrollArea>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -1102,3 +1103,28 @@ onUnmounted(() => {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: hsl(var(--muted-foreground) / 0.3);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: hsl(var(--muted-foreground) / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For Firefox */
|
||||||
|
.custom-scrollbar {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -294,8 +293,8 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Sheet :open="open" @update:open="emit('update:open', $event)">
|
<Sheet :open="open" @update:open="emit('update:open', $event)">
|
||||||
<SheetContent side="right" class="w-full sm:max-w-lg overflow-hidden flex flex-col">
|
<SheetContent side="right" class="w-full sm:max-w-lg overflow-hidden flex flex-col h-[dvh]">
|
||||||
<SheetHeader>
|
<SheetHeader class="shrink-0">
|
||||||
<div class="flex items-center justify-between pr-8">
|
<div class="flex items-center justify-between pr-8">
|
||||||
<div>
|
<div>
|
||||||
<SheetTitle class="flex items-center gap-2">
|
<SheetTitle class="flex items-center gap-2">
|
||||||
@@ -314,10 +313,10 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<Separator class="my-4" />
|
<Separator class="my-4 shrink-0" />
|
||||||
|
|
||||||
<Tabs v-model="activeTab" class="flex-1 flex flex-col overflow-hidden">
|
<Tabs v-model="activeTab" class="flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||||
<TabsList class="w-full grid grid-cols-2">
|
<TabsList class="w-full grid grid-cols-2 shrink-0">
|
||||||
<TabsTrigger value="images">
|
<TabsTrigger value="images">
|
||||||
<Disc class="h-4 w-4 mr-1.5" />
|
<Disc class="h-4 w-4 mr-1.5" />
|
||||||
{{ t('msd.images') }}
|
{{ t('msd.images') }}
|
||||||
@@ -328,11 +327,11 @@ onMounted(async () => {
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<ScrollArea class="flex-1 mt-4">
|
<div class="flex-1 min-h-0 mt-4 flex flex-col">
|
||||||
<!-- Images Tab -->
|
<!-- Images Tab -->
|
||||||
<TabsContent value="images" class="m-0 space-y-4">
|
<TabsContent value="images" class="flex-1 min-h-0 m-0 flex flex-col space-y-4">
|
||||||
<!-- Upload Area -->
|
<!-- Upload Area -->
|
||||||
<div class="space-y-3">
|
<div class="shrink-0 space-y-3">
|
||||||
<label class="block">
|
<label class="block">
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
@@ -352,7 +351,7 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Options -->
|
<!-- Options -->
|
||||||
<div class="flex items-center gap-4 p-3 rounded-lg bg-muted/50">
|
<div class="shrink-0 flex items-center gap-4 p-3 rounded-lg bg-muted/50">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Switch id="cdrom" v-model:checked="cdromMode" />
|
<Switch id="cdrom" v-model:checked="cdromMode" />
|
||||||
<Label for="cdrom" class="text-xs">{{ t('msd.cdromMode') }}</Label>
|
<Label for="cdrom" class="text-xs">{{ t('msd.cdromMode') }}</Label>
|
||||||
@@ -364,19 +363,20 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Image List -->
|
<!-- Image List -->
|
||||||
<div class="space-y-2">
|
<div class="flex-1 min-h-0 flex flex-col space-y-2">
|
||||||
<div class="flex items-center justify-between">
|
<div class="shrink-0 flex items-center justify-between">
|
||||||
<h4 class="text-sm font-medium">{{ t('msd.imageList') }}</h4>
|
<h4 class="text-sm font-medium">{{ t('msd.imageList') }}</h4>
|
||||||
<Button variant="ghost" size="icon" class="h-7 w-7" @click="loadImages">
|
<Button variant="ghost" size="icon" class="h-7 w-7" @click="loadImages">
|
||||||
<RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': loadingImages }" />
|
<RefreshCw class="h-3.5 w-3.5" :class="{ 'animate-spin': loadingImages }" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="images.length === 0" class="text-center py-6 text-muted-foreground text-sm">
|
<div v-if="images.length === 0" class="shrink-0 text-center py-6 text-muted-foreground text-sm">
|
||||||
{{ t('msd.noImages') }}
|
{{ t('msd.noImages') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-1.5">
|
<ScrollArea v-else class="flex-1 min-h-0 pr-4">
|
||||||
|
<div class="space-y-1.5">
|
||||||
<div
|
<div
|
||||||
v-for="image in images"
|
v-for="image in images"
|
||||||
:key="image.id"
|
:key="image.id"
|
||||||
@@ -414,13 +414,14 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<!-- Drive Tab -->
|
<!-- Drive Tab -->
|
||||||
<TabsContent value="drive" class="m-0 space-y-4">
|
<TabsContent value="drive" class="flex-1 min-h-0 m-0 flex flex-col space-y-4">
|
||||||
<template v-if="!driveInitialized">
|
<template v-if="!driveInitialized">
|
||||||
<div class="text-center py-8 space-y-4">
|
<div class="shrink-0 text-center py-8 space-y-4">
|
||||||
<HardDrive class="h-10 w-10 mx-auto text-muted-foreground" />
|
<HardDrive class="h-10 w-10 mx-auto text-muted-foreground" />
|
||||||
<p class="text-sm text-muted-foreground">{{ t('msd.driveNotInitialized') }}</p>
|
<p class="text-sm text-muted-foreground">{{ t('msd.driveNotInitialized') }}</p>
|
||||||
<Button size="sm" @click="initializeDrive">
|
<Button size="sm" @click="initializeDrive">
|
||||||
@@ -431,7 +432,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Drive Info -->
|
<!-- Drive Info -->
|
||||||
<div class="p-3 rounded-lg bg-muted/50 space-y-2">
|
<div class="shrink-0 p-3 rounded-lg bg-muted/50 space-y-2">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="space-y-0.5">
|
<div class="space-y-0.5">
|
||||||
<p class="text-xs text-muted-foreground">{{ t('msd.driveSize') }}: {{ (driveInfo?.size || 0) / 1024 / 1024 }}MB</p>
|
<p class="text-xs text-muted-foreground">{{ t('msd.driveSize') }}: {{ (driveInfo?.size || 0) / 1024 / 1024 }}MB</p>
|
||||||
@@ -459,9 +460,9 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- File Browser -->
|
<!-- File Browser -->
|
||||||
<div class="space-y-2">
|
<div class="flex-1 min-h-0 flex flex-col space-y-2">
|
||||||
<!-- Toolbar -->
|
<!-- Toolbar -->
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="shrink-0 flex items-center justify-between gap-2">
|
||||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||||
<Button
|
<Button
|
||||||
v-if="currentPath !== '/'"
|
v-if="currentPath !== '/'"
|
||||||
@@ -485,7 +486,7 @@ onMounted(async () => {
|
|||||||
</template>
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1 shrink-0">
|
<div class="shrink-0 flex items-center gap-1 shrink-0">
|
||||||
<label>
|
<label>
|
||||||
<input type="file" class="hidden" :disabled="uploadingFile" @change="handleFileUpload" />
|
<input type="file" class="hidden" :disabled="uploadingFile" @change="handleFileUpload" />
|
||||||
<Button variant="ghost" size="icon" as="span" class="h-7 w-7 cursor-pointer">
|
<Button variant="ghost" size="icon" as="span" class="h-7 w-7 cursor-pointer">
|
||||||
@@ -501,14 +502,15 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Progress v-if="uploadingFile" :model-value="fileUploadProgress" class="h-1" />
|
<Progress v-if="uploadingFile" :model-value="fileUploadProgress" class="h-1 shrink-0" />
|
||||||
|
|
||||||
<!-- File List -->
|
<!-- File List -->
|
||||||
<div v-if="driveFiles.length === 0" class="text-center py-6 text-muted-foreground text-sm">
|
<div v-if="driveFiles.length === 0" class="shrink-0 text-center py-6 text-muted-foreground text-sm">
|
||||||
{{ t('msd.emptyFolder') }}
|
{{ t('msd.emptyFolder') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-1">
|
<ScrollArea v-else class="flex-1 min-h-0 pr-4">
|
||||||
|
<div class="space-y-1">
|
||||||
<div
|
<div
|
||||||
v-for="file in driveFiles"
|
v-for="file in driveFiles"
|
||||||
:key="file.path"
|
:key="file.path"
|
||||||
@@ -550,10 +552,11 @@ onMounted(async () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</ScrollArea>
|
</div>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
@@ -588,3 +591,28 @@ onMounted(async () => {
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background: hsl(var(--muted-foreground) / 0.3);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: hsl(var(--muted-foreground) / 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* For Firefox */
|
||||||
|
.custom-scrollbar {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from '@/components/ui/sheet'
|
} from '@/components/ui/sheet'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import type { WebRTCStats } from '@/composables/useWebRTC'
|
import type { WebRTCStats } from '@/composables/useWebRTC'
|
||||||
|
import { formatFpsValue } from '@/lib/fps'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -547,7 +548,7 @@ onUnmounted(() => {
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h4 class="text-sm font-medium">{{ t('stats.frameRate') }}</h4>
|
<h4 class="text-sm font-medium">{{ t('stats.frameRate') }}</h4>
|
||||||
<span class="text-xs text-muted-foreground">
|
<span class="text-xs text-muted-foreground">
|
||||||
{{ currentStats.fps }} fps
|
{{ formatFpsValue(currentStats.fps) }} fps
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -134,12 +134,20 @@ const statusBadgeText = computed(() => {
|
|||||||
:aria-label="`${title}: ${quickInfo || subtitle || statusText}`"
|
:aria-label="`${title}: ${quickInfo || subtitle || statusText}`"
|
||||||
:class="cn(
|
:class="cn(
|
||||||
'flex flex-col gap-0.5 rounded-md border cursor-pointer transition-colors text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
'flex flex-col gap-0.5 rounded-md border cursor-pointer transition-colors text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
compact ? 'px-2 py-1 text-xs min-w-[80px]' : 'px-3 py-1.5 text-sm min-w-[100px]',
|
compact ? 'px-1.5 py-0.5 text-xs' : 'px-3 py-1.5 text-sm min-w-[100px]',
|
||||||
'bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700',
|
'bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700',
|
||||||
'border-slate-200 dark:border-slate-700',
|
'border-slate-200 dark:border-slate-700',
|
||||||
status === 'error' && 'border-red-300 dark:border-red-800'
|
status === 'error' && 'border-red-300 dark:border-red-800'
|
||||||
)"
|
)"
|
||||||
>
|
>
|
||||||
|
<template v-if="compact">
|
||||||
|
<!-- Compact: single row with dot + abbreviated title -->
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span :class="cn('h-1.5 w-1.5 rounded-full shrink-0', statusColor)" />
|
||||||
|
<span class="text-[10px] text-muted-foreground leading-tight truncate">{{ title }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
<!-- Top: Title -->
|
<!-- Top: Title -->
|
||||||
<span class="font-medium text-foreground text-xs truncate">{{ title }}</span>
|
<span class="font-medium text-foreground text-xs truncate">{{ title }}</span>
|
||||||
<!-- Bottom: Status dot + Quick info -->
|
<!-- Bottom: Status dot + Quick info -->
|
||||||
@@ -149,6 +157,7 @@ const statusBadgeText = computed(() => {
|
|||||||
{{ quickInfo || subtitle || statusText }}
|
{{ quickInfo || subtitle || statusText }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</button>
|
</button>
|
||||||
</HoverCardTrigger>
|
</HoverCardTrigger>
|
||||||
|
|
||||||
@@ -188,17 +197,6 @@ const statusBadgeText = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error Message -->
|
|
||||||
<div
|
|
||||||
v-if="status === 'error' && errorMessage"
|
|
||||||
class="p-2 rounded-md bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800"
|
|
||||||
>
|
|
||||||
<p class="text-xs text-red-600 dark:text-red-400">
|
|
||||||
<AlertCircle class="h-3.5 w-3.5 inline mr-1" />
|
|
||||||
{{ errorMessage }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Details -->
|
<!-- Details -->
|
||||||
<div v-if="details && details.length > 0" class="space-y-2">
|
<div v-if="details && details.length > 0" class="space-y-2">
|
||||||
<Separator />
|
<Separator />
|
||||||
@@ -206,12 +204,12 @@ const statusBadgeText = computed(() => {
|
|||||||
<div
|
<div
|
||||||
v-for="(detail, index) in details"
|
v-for="(detail, index) in details"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="flex items-center justify-between text-xs"
|
class="flex items-start justify-between gap-3 text-xs"
|
||||||
>
|
>
|
||||||
<span class="text-muted-foreground">{{ detail.label }}</span>
|
<span class="text-muted-foreground shrink-0">{{ detail.label }}</span>
|
||||||
<span
|
<span
|
||||||
:class="cn(
|
:class="cn(
|
||||||
'font-medium',
|
'font-medium text-right break-words min-w-0',
|
||||||
detail.status === 'ok' ? 'text-green-600 dark:text-green-400' :
|
detail.status === 'ok' ? 'text-green-600 dark:text-green-400' :
|
||||||
detail.status === 'warning' ? 'text-yellow-600 dark:text-yellow-400' :
|
detail.status === 'warning' ? 'text-yellow-600 dark:text-yellow-400' :
|
||||||
detail.status === 'error' ? 'text-red-600 dark:text-red-400' :
|
detail.status === 'error' ? 'text-red-600 dark:text-red-400' :
|
||||||
@@ -235,12 +233,20 @@ const statusBadgeText = computed(() => {
|
|||||||
:aria-label="`${title}: ${quickInfo || subtitle || statusText}`"
|
:aria-label="`${title}: ${quickInfo || subtitle || statusText}`"
|
||||||
:class="cn(
|
:class="cn(
|
||||||
'flex flex-col gap-0.5 rounded-md border cursor-pointer transition-colors text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
'flex flex-col gap-0.5 rounded-md border cursor-pointer transition-colors text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
compact ? 'px-2 py-1 text-xs min-w-[80px]' : 'px-3 py-1.5 text-sm min-w-[100px]',
|
compact ? 'px-1.5 py-0.5 text-xs' : 'px-3 py-1.5 text-sm min-w-[100px]',
|
||||||
'bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700',
|
'bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-700',
|
||||||
'border-slate-200 dark:border-slate-700',
|
'border-slate-200 dark:border-slate-700',
|
||||||
status === 'error' && 'border-red-300 dark:border-red-800'
|
status === 'error' && 'border-red-300 dark:border-red-800'
|
||||||
)"
|
)"
|
||||||
>
|
>
|
||||||
|
<template v-if="compact">
|
||||||
|
<!-- Compact: single row with dot + abbreviated title -->
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<span :class="cn('h-1.5 w-1.5 rounded-full shrink-0', statusColor)" />
|
||||||
|
<span class="text-[10px] text-muted-foreground leading-tight truncate">{{ title }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
<!-- Top: Title -->
|
<!-- Top: Title -->
|
||||||
<span class="font-medium text-foreground text-xs truncate">{{ title }}</span>
|
<span class="font-medium text-foreground text-xs truncate">{{ title }}</span>
|
||||||
<!-- Bottom: Status dot + Quick info -->
|
<!-- Bottom: Status dot + Quick info -->
|
||||||
@@ -250,10 +256,11 @@ const statusBadgeText = computed(() => {
|
|||||||
{{ quickInfo || subtitle || statusText }}
|
{{ quickInfo || subtitle || statusText }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</button>
|
</button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
||||||
<PopoverContent class="w-80" :align="hoverAlign">
|
<PopoverContent class="w-[min(320px,90vw)]" :align="hoverAlign">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -289,17 +296,6 @@ const statusBadgeText = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Error Message -->
|
|
||||||
<div
|
|
||||||
v-if="status === 'error' && errorMessage"
|
|
||||||
class="p-2 rounded-md bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800"
|
|
||||||
>
|
|
||||||
<p class="text-xs text-red-600 dark:text-red-400">
|
|
||||||
<AlertCircle class="h-3.5 w-3.5 inline mr-1" />
|
|
||||||
{{ errorMessage }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Details -->
|
<!-- Details -->
|
||||||
<div v-if="details && details.length > 0" class="space-y-2">
|
<div v-if="details && details.length > 0" class="space-y-2">
|
||||||
<Separator />
|
<Separator />
|
||||||
@@ -307,12 +303,12 @@ const statusBadgeText = computed(() => {
|
|||||||
<div
|
<div
|
||||||
v-for="(detail, index) in details"
|
v-for="(detail, index) in details"
|
||||||
:key="index"
|
:key="index"
|
||||||
class="flex items-center justify-between text-xs"
|
class="flex items-start justify-between gap-3 text-xs"
|
||||||
>
|
>
|
||||||
<span class="text-muted-foreground">{{ detail.label }}</span>
|
<span class="text-muted-foreground shrink-0">{{ detail.label }}</span>
|
||||||
<span
|
<span
|
||||||
:class="cn(
|
:class="cn(
|
||||||
'font-medium',
|
'font-medium text-right break-words min-w-0',
|
||||||
detail.status === 'ok' ? 'text-green-600 dark:text-green-400' :
|
detail.status === 'ok' ? 'text-green-600 dark:text-green-400' :
|
||||||
detail.status === 'warning' ? 'text-yellow-600 dark:text-yellow-400' :
|
detail.status === 'warning' ? 'text-yellow-600 dark:text-yellow-400' :
|
||||||
detail.status === 'error' ? 'text-red-600 dark:text-red-400' :
|
detail.status === 'error' ? 'text-red-600 dark:text-red-400' :
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
type StreamConstraintsResponse,
|
type StreamConstraintsResponse,
|
||||||
} from '@/api'
|
} from '@/api'
|
||||||
import { getVideoFormatState, isVideoFormatSelectable } from '@/lib/video-format-support'
|
import { getVideoFormatState, isVideoFormatSelectable } from '@/lib/video-format-support'
|
||||||
|
import { formatFpsLabel, toConfigFps } from '@/lib/fps'
|
||||||
import { useConfigStore } from '@/stores/config'
|
import { useConfigStore } from '@/stores/config'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
@@ -549,7 +550,7 @@ async function applyVideoConfig() {
|
|||||||
format: selectedFormat.value,
|
format: selectedFormat.value,
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
fps: selectedFps.value,
|
fps: toConfigFps(selectedFps.value),
|
||||||
})
|
})
|
||||||
|
|
||||||
toast.success(t('config.applied'))
|
toast.success(t('config.applied'))
|
||||||
@@ -631,12 +632,12 @@ watch(
|
|||||||
<template>
|
<template>
|
||||||
<Popover :open="open" @update:open="emit('update:open', $event)">
|
<Popover :open="open" @update:open="emit('update:open', $event)">
|
||||||
<PopoverTrigger as-child>
|
<PopoverTrigger as-child>
|
||||||
<Button variant="ghost" size="sm" class="h-8 gap-1.5 text-xs">
|
<Button variant="ghost" size="sm" class="h-7 w-7 sm:h-8 sm:w-auto p-0 sm:px-2 sm:gap-1.5 text-xs">
|
||||||
<Monitor class="h-4 w-4" />
|
<Monitor class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||||
<span class="hidden sm:inline">{{ buttonText }}</span>
|
<span class="hidden sm:inline">{{ buttonText }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent class="w-[320px] p-3" align="start">
|
<PopoverContent class="w-[min(320px,92vw)] p-3" align="start">
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<h4 class="text-sm font-medium">{{ t('actionbar.videoConfig') }}</h4>
|
<h4 class="text-sm font-medium">{{ t('actionbar.videoConfig') }}</h4>
|
||||||
|
|
||||||
@@ -926,7 +927,7 @@ watch(
|
|||||||
:value="String(fps)"
|
:value="String(fps)"
|
||||||
class="text-xs"
|
class="text-xs"
|
||||||
>
|
>
|
||||||
{{ fps }} FPS
|
{{ formatFpsLabel(fps) }}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -1202,12 +1202,17 @@ html.dark .hg-theme-default .hg-button.down-key,
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@media (max-width: 640px) {
|
||||||
|
.vkb-body {
|
||||||
|
padding: 4px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
.vkb .simple-keyboard .hg-button {
|
.vkb .simple-keyboard .hg-button {
|
||||||
height: 30px;
|
height: 28px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
padding: 0 4px;
|
padding: 0 3px;
|
||||||
margin: 0 1px 3px 0;
|
margin: 0 1px 2px 0;
|
||||||
min-width: 26px;
|
min-width: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vkb .simple-keyboard .hg-button.combination-key {
|
.vkb .simple-keyboard .hg-button.combination-key {
|
||||||
@@ -1275,6 +1280,85 @@ html.dark .hg-theme-default .hg-button.down-key,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 400px) {
|
||||||
|
.vkb .simple-keyboard .hg-button {
|
||||||
|
height: 26px;
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 0 2px;
|
||||||
|
margin: 0 1px 2px 0;
|
||||||
|
min-width: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vkb .simple-keyboard .hg-button[data-skbtn="Space"] {
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vkb .simple-keyboard .hg-button[data-skbtn="Backspace"],
|
||||||
|
.vkb .simple-keyboard .hg-button[data-skbtn="Tab"],
|
||||||
|
.vkb .simple-keyboard .hg-button[data-skbtn="Backslash"],
|
||||||
|
.vkb .simple-keyboard .hg-button[data-skbtn="(Backslash)"],
|
||||||
|
.vkb .simple-keyboard .hg-button[data-skbtn="CapsLock"] {
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vkb .simple-keyboard .hg-button[data-skbtn="Enter"],
|
||||||
|
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftLeft"],
|
||||||
|
.vkb .simple-keyboard .hg-button[data-skbtn="ShiftRight"] {
|
||||||
|
min-width: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vkb .simple-keyboard .hg-button[data-skbtn="ControlLeft"],
|
||||||
|
.vkb .simple-keyboard .hg-button[data-skbtn="ControlRight"],
|
||||||
|
.vkb .simple-keyboard .hg-button[data-skbtn="MetaLeft"],
|
||||||
|
.vkb .simple-keyboard .hg-button[data-skbtn="MetaRight"],
|
||||||
|
.vkb .simple-keyboard .hg-button[data-skbtn="AltLeft"],
|
||||||
|
.vkb .simple-keyboard .hg-button[data-skbtn="AltRight"],
|
||||||
|
.vkb .simple-keyboard .hg-button[data-skbtn="ContextMenu"] {
|
||||||
|
min-width: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vkb .simple-keyboard .hg-button.combination-key {
|
||||||
|
font-size: 8px;
|
||||||
|
height: 22px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-control-container .hg-button {
|
||||||
|
min-width: 34px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kb-arrows-container .hg-button {
|
||||||
|
min-width: 30px !important;
|
||||||
|
width: 30px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vkb-media-btn {
|
||||||
|
padding: 3px 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
min-width: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vkb-header {
|
||||||
|
padding: 2px 6px;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vkb-btn {
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vkb-os-btn {
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vkb-title {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Floating mode - slightly smaller keys but still readable */
|
/* Floating mode - slightly smaller keys but still readable */
|
||||||
.vkb--floating .vkb-body {
|
.vkb--floating .vkb-body {
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
|
|||||||
@@ -334,15 +334,14 @@ async function addRemoteIceCandidate(candidate: IceCandidate) {
|
|||||||
async function flushPendingRemoteIce() {
|
async function flushPendingRemoteIce() {
|
||||||
if (!peerConnection || !sessionId || !peerConnection.remoteDescription) return
|
if (!peerConnection || !sessionId || !peerConnection.remoteDescription) return
|
||||||
|
|
||||||
const remaining: WebRTCIceCandidateEvent[] = []
|
const queued = pendingRemoteCandidates
|
||||||
for (const event of pendingRemoteCandidates) {
|
pendingRemoteCandidates = []
|
||||||
|
|
||||||
|
for (const event of queued) {
|
||||||
if (event.session_id === sessionId) {
|
if (event.session_id === sessionId) {
|
||||||
await addRemoteIceCandidate(event.candidate)
|
await addRemoteIceCandidate(event.candidate)
|
||||||
} else {
|
|
||||||
// Drop candidates for old sessions
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
pendingRemoteCandidates = remaining
|
|
||||||
|
|
||||||
if (pendingRemoteIceComplete.has(sessionId)) {
|
if (pendingRemoteIceComplete.has(sessionId)) {
|
||||||
pendingRemoteIceComplete.delete(sessionId)
|
pendingRemoteIceComplete.delete(sessionId)
|
||||||
@@ -546,10 +545,8 @@ async function connect(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 等待连接真正建立(最多等待 15 秒)
|
// Wait for connection to establish (5s for LAN, sufficient for most scenarios)
|
||||||
// 直接检查 peerConnection.connectionState 而不是 reactive state
|
const connectionTimeout = 5000
|
||||||
// 因为 TypeScript 不知道 state 会被 onconnectionstatechange 回调异步修改
|
|
||||||
const connectionTimeout = 15000
|
|
||||||
const pollInterval = 100
|
const pollInterval = 100
|
||||||
let waited = 0
|
let waited = 0
|
||||||
connectStage.value = 'waiting_connection'
|
connectStage.value = 'waiting_connection'
|
||||||
@@ -568,7 +565,6 @@ async function connect(): Promise<boolean> {
|
|||||||
waited += pollInterval
|
waited += pollInterval
|
||||||
}
|
}
|
||||||
|
|
||||||
// 超时
|
|
||||||
throw new Error('Connection timeout waiting for ICE negotiation')
|
throw new Error('Connection timeout waiting for ICE negotiation')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
state.value = 'failed'
|
state.value = 'failed'
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export default {
|
|||||||
expand: 'Expand',
|
expand: 'Expand',
|
||||||
toggleTheme: 'Toggle theme',
|
toggleTheme: 'Toggle theme',
|
||||||
toggleLanguage: 'Toggle language',
|
toggleLanguage: 'Toggle language',
|
||||||
|
retry: 'Retry',
|
||||||
},
|
},
|
||||||
api: {
|
api: {
|
||||||
operationFailed: 'Operation Failed',
|
operationFailed: 'Operation Failed',
|
||||||
@@ -64,6 +65,7 @@ export default {
|
|||||||
enterPassword: 'Enter password',
|
enterPassword: 'Enter password',
|
||||||
loginFailed: 'Login failed',
|
loginFailed: 'Login failed',
|
||||||
invalidPassword: 'Invalid username or password',
|
invalidPassword: 'Invalid username or password',
|
||||||
|
systemNotInitialized: 'System not initialized. Complete setup first.',
|
||||||
changePassword: 'Change Password',
|
changePassword: 'Change Password',
|
||||||
currentPassword: 'Current Password',
|
currentPassword: 'Current Password',
|
||||||
currentPasswordPlaceholder: 'Enter current password',
|
currentPasswordPlaceholder: 'Enter current password',
|
||||||
@@ -78,6 +80,9 @@ export default {
|
|||||||
userNotFound: 'User not found',
|
userNotFound: 'User not found',
|
||||||
sessionExpired: 'Session expired',
|
sessionExpired: 'Session expired',
|
||||||
loggedInElsewhere: 'Logged in elsewhere',
|
loggedInElsewhere: 'Logged in elsewhere',
|
||||||
|
forgotPassword: 'Forgot password',
|
||||||
|
forgotPasswordHint:
|
||||||
|
'Forgot your password? On the host running One-KVM, open a terminal and run one-kvm user set-password, then enter and confirm the new password when prompted.',
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
connected: 'Connected',
|
connected: 'Connected',
|
||||||
@@ -105,7 +110,7 @@ export default {
|
|||||||
fullscreen: 'Fullscreen',
|
fullscreen: 'Fullscreen',
|
||||||
fullscreenTip: 'Toggle fullscreen mode',
|
fullscreenTip: 'Toggle fullscreen mode',
|
||||||
// Video Config
|
// Video Config
|
||||||
videoConfig: 'Video Config',
|
videoConfig: 'Video',
|
||||||
streamSettings: 'Stream Settings',
|
streamSettings: 'Stream Settings',
|
||||||
deviceSettings: 'Device Settings',
|
deviceSettings: 'Device Settings',
|
||||||
videoMode: 'Mode',
|
videoMode: 'Mode',
|
||||||
@@ -137,7 +142,7 @@ export default {
|
|||||||
multiSourceCodecLocked: '{sources} are enabled. Current codec is locked.',
|
multiSourceCodecLocked: '{sources} are enabled. Current codec is locked.',
|
||||||
multiSourceVideoParamsWarning: '{sources} are enabled. Changing video device and input parameters will interrupt the stream.',
|
multiSourceVideoParamsWarning: '{sources} are enabled. Changing video device and input parameters will interrupt the stream.',
|
||||||
// HID Config
|
// HID Config
|
||||||
hidConfig: 'Mouse & HID',
|
hidConfig: 'HID',
|
||||||
mouseSettings: 'Mouse Settings',
|
mouseSettings: 'Mouse Settings',
|
||||||
hidDeviceSettings: 'HID Device Settings',
|
hidDeviceSettings: 'HID Device Settings',
|
||||||
positioningMode: 'Positioning Mode',
|
positioningMode: 'Positioning Mode',
|
||||||
@@ -268,9 +273,6 @@ export default {
|
|||||||
// Help tooltips
|
// Help tooltips
|
||||||
ch9329Help: 'CH9329 is a serial-to-HID chip connected via serial port. Works with most hardware configurations.',
|
ch9329Help: 'CH9329 is a serial-to-HID chip connected via serial port. Works with most hardware configurations.',
|
||||||
otgHelp: 'USB OTG mode emulates HID devices directly through USB Device Controller. Requires hardware OTG support.',
|
otgHelp: 'USB OTG mode emulates HID devices directly through USB Device Controller. Requires hardware OTG support.',
|
||||||
otgAdvanced: 'Advanced: OTG Preset',
|
|
||||||
otgProfile: 'Initial HID Preset',
|
|
||||||
otgProfileDesc: 'Choose the initial OTG HID preset. You can change this later in Settings.',
|
|
||||||
otgLowEndpointHint: 'Detected low-endpoint UDC; Consumer Control Keyboard will be disabled automatically.',
|
otgLowEndpointHint: 'Detected low-endpoint UDC; Consumer Control Keyboard will be disabled automatically.',
|
||||||
videoDeviceHelp: 'Select the video capture device for capturing the remote host display. Usually an HDMI capture card.',
|
videoDeviceHelp: 'Select the video capture device for capturing the remote host display. Usually an HDMI capture card.',
|
||||||
videoFormatHelp: 'MJPEG has best compatibility. H.264/H.265 uses less bandwidth but requires encoding support.',
|
videoFormatHelp: 'MJPEG has best compatibility. H.264/H.265 uses less bandwidth but requires encoding support.',
|
||||||
@@ -500,7 +502,6 @@ export default {
|
|||||||
buildInfo: 'Build Info',
|
buildInfo: 'Build Info',
|
||||||
detectDevices: 'Detect Devices',
|
detectDevices: 'Detect Devices',
|
||||||
detecting: 'Detecting...',
|
detecting: 'Detecting...',
|
||||||
builtWith: "Copyright {'@'}2025 SilentWind",
|
|
||||||
networkSettings: 'Network Settings',
|
networkSettings: 'Network Settings',
|
||||||
msdSettings: 'MSD Settings',
|
msdSettings: 'MSD Settings',
|
||||||
atxSettings: 'ATX Settings',
|
atxSettings: 'ATX Settings',
|
||||||
@@ -527,10 +528,43 @@ export default {
|
|||||||
addBindAddress: 'Add address',
|
addBindAddress: 'Add address',
|
||||||
bindAddressListEmpty: 'Add at least one IP address.',
|
bindAddressListEmpty: 'Add at least one IP address.',
|
||||||
httpsEnabled: 'Enable HTTPS',
|
httpsEnabled: 'Enable HTTPS',
|
||||||
httpsEnabledDesc: 'Enable HTTPS encrypted connection (self-signed certificate will be auto-generated)',
|
httpsEnabledDesc: 'Enable HTTPS encrypted connection (a self-signed certificate is generated if none is specified)',
|
||||||
|
// Port config
|
||||||
|
portConfig: 'Port & Protocol',
|
||||||
|
portConfigDesc: 'The service runs on a single port at a time, determined by the HTTPS toggle',
|
||||||
|
httpPortReserved: 'HTTP port (reserved)',
|
||||||
|
httpsPortReserved: 'HTTPS port (reserved)',
|
||||||
|
previewUrl: 'Access URL preview',
|
||||||
|
// Listen address
|
||||||
|
listenAddress: 'Listen Address',
|
||||||
|
listenAddressDesc: 'Configure which network interfaces the web server listens on',
|
||||||
|
bindModeAllDesc: '0.0.0.0 — Listen on all network interfaces',
|
||||||
|
bindModeLocalDesc: '127.0.0.1 — Allow local access only',
|
||||||
|
bindModeCustomDesc: 'Specify a list of IP addresses',
|
||||||
|
effectiveAddresses: 'Listen address preview',
|
||||||
|
// SSL certificate
|
||||||
|
sslCertificate: 'SSL Certificate',
|
||||||
|
sslCertificateDesc: 'Upload a custom PEM certificate to replace the self-signed one, restart required',
|
||||||
|
sslCertCustom: 'Custom Certificate',
|
||||||
|
sslCertSelfSigned: 'Self-Signed',
|
||||||
|
sslCertActive: 'Custom certificate is active',
|
||||||
|
sslCertClear: 'Revert to Self-Signed',
|
||||||
|
sslCertSave: 'Save Certificate',
|
||||||
|
sslCertPem: 'Certificate (.crt / .pem)',
|
||||||
|
sslKeyPem: 'Private Key (.key)',
|
||||||
|
sslCertPemPlaceholder: '-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----',
|
||||||
|
sslKeyPemPlaceholder: '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----',
|
||||||
|
sslCertSaved: 'Certificate saved, restart to apply',
|
||||||
|
sslCertCleared: 'Reverted to self-signed certificate, restart to apply',
|
||||||
restartRequired: 'Restart Required',
|
restartRequired: 'Restart Required',
|
||||||
restartMessage: 'Web server configuration saved. A restart is required for changes to take effect.',
|
restartMessage: 'Web server configuration saved. A restart is required for changes to take effect.',
|
||||||
restarting: 'Restarting...',
|
restarting: 'Restarting...',
|
||||||
|
autoRestarting: 'Restarting automatically',
|
||||||
|
autoRestartingDesc: 'Configuration saved. Will redirect to the new address once the service is back...',
|
||||||
|
autoRestartingHttpsDesc: 'Service is restarting. A redirect link will appear in {sec}s...',
|
||||||
|
autoRestartFailed: 'Auto-restart timed out. Please refresh the page or check the service status.',
|
||||||
|
httpsManualRedirectTitle: 'Click the link below to open the new address',
|
||||||
|
httpsManualRedirectDesc: 'HTTPS with a self-signed certificate requires browser approval. Click the link and choose "Proceed" on the security warning.',
|
||||||
onlineUpgrade: 'Online Upgrade',
|
onlineUpgrade: 'Online Upgrade',
|
||||||
onlineUpgradeDesc: 'Check and upgrade One-KVM',
|
onlineUpgradeDesc: 'Check and upgrade One-KVM',
|
||||||
updateChannel: 'Update Channel',
|
updateChannel: 'Update Channel',
|
||||||
@@ -560,7 +594,6 @@ export default {
|
|||||||
authSettingsDesc: 'Single-user access and session behavior',
|
authSettingsDesc: 'Single-user access and session behavior',
|
||||||
allowMultipleSessions: 'Allow multiple web sessions',
|
allowMultipleSessions: 'Allow multiple web sessions',
|
||||||
allowMultipleSessionsDesc: 'When disabled, a new login will kick the previous session.',
|
allowMultipleSessionsDesc: 'When disabled, a new login will kick the previous session.',
|
||||||
singleUserSessionNote: 'Single-user mode is enforced; only session concurrency is configurable.',
|
|
||||||
// User management
|
// User management
|
||||||
userManagement: 'User Management',
|
userManagement: 'User Management',
|
||||||
userManagementDesc: 'Manage user accounts and permissions',
|
userManagementDesc: 'Manage user accounts and permissions',
|
||||||
@@ -820,27 +853,19 @@ export default {
|
|||||||
version: 'Version',
|
version: 'Version',
|
||||||
uptime: 'Uptime',
|
uptime: 'Uptime',
|
||||||
running: 'Running',
|
running: 'Running',
|
||||||
mode: 'Mode',
|
|
||||||
format: 'Format',
|
format: 'Format',
|
||||||
resolution: 'Resolution',
|
resolution: 'Resolution',
|
||||||
targetFps: 'Target FPS',
|
fps: 'FPS',
|
||||||
fps: 'Actual FPS',
|
|
||||||
clients: 'Clients',
|
clients: 'Clients',
|
||||||
backend: 'Backend',
|
backend: 'Backend',
|
||||||
initialized: 'Initialized',
|
|
||||||
yes: 'Yes',
|
|
||||||
no: 'No',
|
|
||||||
mouse: 'Mouse',
|
mouse: 'Mouse',
|
||||||
mouseSupport: 'Mouse Support',
|
mouseSupport: 'Mouse Support',
|
||||||
currentMode: 'Current Mode',
|
currentMode: 'Current Mode',
|
||||||
absolute: 'Absolute',
|
absolute: 'Absolute',
|
||||||
relative: 'Relative',
|
relative: 'Relative',
|
||||||
connection: 'Connection',
|
|
||||||
channel: 'Channel',
|
channel: 'Channel',
|
||||||
networkError: 'Network Error',
|
networkError: 'Network Error',
|
||||||
disconnected: 'Disconnected',
|
disconnected: 'Disconnected',
|
||||||
availability: 'Availability',
|
|
||||||
errorCode: 'Error Code',
|
|
||||||
hidUnavailable: 'HID Unavailable',
|
hidUnavailable: 'HID Unavailable',
|
||||||
sampleRate: 'Sample Rate',
|
sampleRate: 'Sample Rate',
|
||||||
channels: 'Channels',
|
channels: 'Channels',
|
||||||
@@ -892,6 +917,7 @@ export default {
|
|||||||
title: 'GOSTC NAT Traversal',
|
title: 'GOSTC NAT Traversal',
|
||||||
desc: 'NAT traversal via GOSTC',
|
desc: 'NAT traversal via GOSTC',
|
||||||
addr: 'Server Address',
|
addr: 'Server Address',
|
||||||
|
addrPlaceholder: 'Hostname or IP (required)',
|
||||||
key: 'Client Key',
|
key: 'Client Key',
|
||||||
tls: 'Enable TLS',
|
tls: 'Enable TLS',
|
||||||
},
|
},
|
||||||
@@ -918,9 +944,9 @@ export default {
|
|||||||
relayServerPlaceholder: 'hbbr.example.com:21117',
|
relayServerPlaceholder: 'hbbr.example.com:21117',
|
||||||
relayServerHint: 'Relay server address (port optional, defaults to 21117). Auto-derived if empty',
|
relayServerHint: 'Relay server address (port optional, defaults to 21117). Auto-derived if empty',
|
||||||
relayKey: 'Relay Key',
|
relayKey: 'Relay Key',
|
||||||
relayKeyPlaceholder: 'Enter relay server key',
|
relayKeyPlaceholder: 'e.g. pLU0pEj2IZnNVKzrIO1pIdwGA3dOVJJLkFIYGOCGH1E=',
|
||||||
relayKeySet: '••••••••',
|
relayKeySet: 'Saved (32-byte Base64, usually 44 chars; leave empty and save to keep)',
|
||||||
relayKeyHint: 'Authentication key for relay server (if server uses -k option)',
|
relayKeyHint: 'Same as hbbs/hbbr -k: standard Base64 decoding to exactly 32 bytes (typically 44 characters including trailing =)',
|
||||||
deviceInfo: 'Device Info',
|
deviceInfo: 'Device Info',
|
||||||
deviceId: 'Device ID',
|
deviceId: 'Device ID',
|
||||||
deviceIdHint: 'Use this ID in RustDesk client to connect',
|
deviceIdHint: 'Use this ID in RustDesk client to connect',
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ export default {
|
|||||||
expand: '展开',
|
expand: '展开',
|
||||||
toggleTheme: '切换主题',
|
toggleTheme: '切换主题',
|
||||||
toggleLanguage: '切换语言',
|
toggleLanguage: '切换语言',
|
||||||
|
retry: '重试',
|
||||||
},
|
},
|
||||||
api: {
|
api: {
|
||||||
operationFailed: '操作失败',
|
operationFailed: '操作失败',
|
||||||
@@ -64,6 +65,7 @@ export default {
|
|||||||
enterPassword: '请输入密码',
|
enterPassword: '请输入密码',
|
||||||
loginFailed: '登录失败',
|
loginFailed: '登录失败',
|
||||||
invalidPassword: '用户名或密码错误',
|
invalidPassword: '用户名或密码错误',
|
||||||
|
systemNotInitialized: '系统尚未初始化,请先完成向导设置。',
|
||||||
changePassword: '修改密码',
|
changePassword: '修改密码',
|
||||||
currentPassword: '当前密码',
|
currentPassword: '当前密码',
|
||||||
currentPasswordPlaceholder: '请输入当前密码',
|
currentPasswordPlaceholder: '请输入当前密码',
|
||||||
@@ -78,6 +80,9 @@ export default {
|
|||||||
userNotFound: '用户不存在',
|
userNotFound: '用户不存在',
|
||||||
sessionExpired: '会话已过期',
|
sessionExpired: '会话已过期',
|
||||||
loggedInElsewhere: '已在别处登录',
|
loggedInElsewhere: '已在别处登录',
|
||||||
|
forgotPassword: '忘记密码',
|
||||||
|
forgotPasswordHint:
|
||||||
|
'忘记密码?在运行本服务的设备上打开终端,执行 one-kvm user set-password,按提示输入并确认新密码即可重置。',
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
connected: '已连接',
|
connected: '已连接',
|
||||||
@@ -268,9 +273,6 @@ export default {
|
|||||||
// Help tooltips
|
// Help tooltips
|
||||||
ch9329Help: 'CH9329 是一款串口转 HID 芯片,通过串口连接到主机。适用于大多数硬件配置。',
|
ch9329Help: 'CH9329 是一款串口转 HID 芯片,通过串口连接到主机。适用于大多数硬件配置。',
|
||||||
otgHelp: 'USB OTG 模式通过 USB 设备控制器直接模拟 HID 设备。需要硬件支持 USB OTG 功能。',
|
otgHelp: 'USB OTG 模式通过 USB 设备控制器直接模拟 HID 设备。需要硬件支持 USB OTG 功能。',
|
||||||
otgAdvanced: '高级:OTG 预设',
|
|
||||||
otgProfile: '初始 HID 预设',
|
|
||||||
otgProfileDesc: '选择 OTG HID 的初始预设,后续可在设置中修改。',
|
|
||||||
otgLowEndpointHint: '检测到低端点 UDC,将自动禁用多媒体键盘。',
|
otgLowEndpointHint: '检测到低端点 UDC,将自动禁用多媒体键盘。',
|
||||||
videoDeviceHelp: '选择用于捕获远程主机画面的视频采集设备。通常是 HDMI 采集卡。',
|
videoDeviceHelp: '选择用于捕获远程主机画面的视频采集设备。通常是 HDMI 采集卡。',
|
||||||
videoFormatHelp: 'MJPEG 格式兼容性最好,H.264/H.265 带宽占用更低但需要编码支持。',
|
videoFormatHelp: 'MJPEG 格式兼容性最好,H.264/H.265 带宽占用更低但需要编码支持。',
|
||||||
@@ -500,7 +502,6 @@ export default {
|
|||||||
buildInfo: '构建信息',
|
buildInfo: '构建信息',
|
||||||
detectDevices: '探测设备',
|
detectDevices: '探测设备',
|
||||||
detecting: '探测中...',
|
detecting: '探测中...',
|
||||||
builtWith: "版权信息 {'@'}2025 SilentWind",
|
|
||||||
networkSettings: '网络设置',
|
networkSettings: '网络设置',
|
||||||
msdSettings: 'MSD 设置',
|
msdSettings: 'MSD 设置',
|
||||||
atxSettings: 'ATX 设置',
|
atxSettings: 'ATX 设置',
|
||||||
@@ -527,10 +528,43 @@ export default {
|
|||||||
addBindAddress: '添加地址',
|
addBindAddress: '添加地址',
|
||||||
bindAddressListEmpty: '请至少填写一个 IP 地址。',
|
bindAddressListEmpty: '请至少填写一个 IP 地址。',
|
||||||
httpsEnabled: '启用 HTTPS',
|
httpsEnabled: '启用 HTTPS',
|
||||||
httpsEnabledDesc: '启用 HTTPS 加密连接(将自动生成自签名证书)',
|
httpsEnabledDesc: '启用 HTTPS 加密连接(未指定证书将生成自签证书)',
|
||||||
|
// Port config
|
||||||
|
portConfig: '端口与协议',
|
||||||
|
portConfigDesc: '服务一次只运行在一个端口上,由 HTTPS 开关决定使用哪个端口',
|
||||||
|
httpPortReserved: 'HTTP 端口(备用)',
|
||||||
|
httpsPortReserved: 'HTTPS 端口(备用)',
|
||||||
|
previewUrl: '访问地址预览',
|
||||||
|
// Listen address
|
||||||
|
listenAddress: '监听地址',
|
||||||
|
listenAddressDesc: '配置 Web 服务监听哪些网络接口',
|
||||||
|
bindModeAllDesc: '0.0.0.0 — 监听所有网络接口',
|
||||||
|
bindModeLocalDesc: '127.0.0.1 — 仅允许本机访问',
|
||||||
|
bindModeCustomDesc: '指定一组 IP 地址',
|
||||||
|
effectiveAddresses: '监听地址预览',
|
||||||
|
// SSL certificate
|
||||||
|
sslCertificate: 'SSL 证书',
|
||||||
|
sslCertificateDesc: '上传自定义 PEM 证书替换自签名证书,修改后需要重启生效',
|
||||||
|
sslCertCustom: '自定义证书',
|
||||||
|
sslCertSelfSigned: '自签名证书',
|
||||||
|
sslCertActive: '自定义证书已启用',
|
||||||
|
sslCertClear: '恢复自签名',
|
||||||
|
sslCertSave: '保存证书',
|
||||||
|
sslCertPem: '证书内容 (.crt / .pem)',
|
||||||
|
sslKeyPem: '私钥内容 (.key)',
|
||||||
|
sslCertPemPlaceholder: '-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----',
|
||||||
|
sslKeyPemPlaceholder: '-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----',
|
||||||
|
sslCertSaved: '证书已保存,重启后生效',
|
||||||
|
sslCertCleared: '已恢复自签名证书,重启后生效',
|
||||||
restartRequired: '需要重启',
|
restartRequired: '需要重启',
|
||||||
restartMessage: 'Web 服务器配置已保存,需要重启程序才能生效。',
|
restartMessage: 'Web 服务器配置已保存,需要重启程序才能生效。',
|
||||||
restarting: '正在重启...',
|
restarting: '正在重启...',
|
||||||
|
autoRestarting: '正在自动重启',
|
||||||
|
autoRestartingDesc: '配置已保存,服务恢复后将自动跳转到新地址...',
|
||||||
|
autoRestartingHttpsDesc: '服务即将重启,{sec} 秒后将显示跳转链接...',
|
||||||
|
autoRestartFailed: '自动重启超时,请手动刷新页面或检查服务状态。',
|
||||||
|
httpsManualRedirectTitle: '请点击下方链接前往新地址',
|
||||||
|
httpsManualRedirectDesc: 'HTTPS 自签名证书需要在浏览器中手动接受,点击链接后在安全警告页选择"继续访问"即可。',
|
||||||
onlineUpgrade: '在线升级',
|
onlineUpgrade: '在线升级',
|
||||||
onlineUpgradeDesc: '检查并升级 One-KVM',
|
onlineUpgradeDesc: '检查并升级 One-KVM',
|
||||||
updateChannel: '升级通道',
|
updateChannel: '升级通道',
|
||||||
@@ -560,7 +594,6 @@ export default {
|
|||||||
authSettingsDesc: '单用户访问与会话策略',
|
authSettingsDesc: '单用户访问与会话策略',
|
||||||
allowMultipleSessions: '允许多个 Web 会话',
|
allowMultipleSessions: '允许多个 Web 会话',
|
||||||
allowMultipleSessionsDesc: '关闭后,新登录会踢掉旧会话。',
|
allowMultipleSessionsDesc: '关闭后,新登录会踢掉旧会话。',
|
||||||
singleUserSessionNote: '系统固定为单用户模式,仅可配置会话并发方式。',
|
|
||||||
// User management
|
// User management
|
||||||
userManagement: '用户管理',
|
userManagement: '用户管理',
|
||||||
userManagementDesc: '管理用户账号和权限',
|
userManagementDesc: '管理用户账号和权限',
|
||||||
@@ -820,27 +853,19 @@ export default {
|
|||||||
version: '版本',
|
version: '版本',
|
||||||
uptime: '运行时间',
|
uptime: '运行时间',
|
||||||
running: '运行中',
|
running: '运行中',
|
||||||
mode: '模式',
|
|
||||||
format: '格式',
|
format: '格式',
|
||||||
resolution: '分辨率',
|
resolution: '分辨率',
|
||||||
targetFps: '目标帧率',
|
fps: '帧率',
|
||||||
fps: '实际帧率',
|
|
||||||
clients: '客户端',
|
clients: '客户端',
|
||||||
backend: '后端',
|
backend: '后端',
|
||||||
initialized: '已初始化',
|
|
||||||
yes: '是',
|
|
||||||
no: '否',
|
|
||||||
mouse: '鼠标',
|
mouse: '鼠标',
|
||||||
mouseSupport: '鼠标支持',
|
mouseSupport: '鼠标支持',
|
||||||
currentMode: '当前模式',
|
currentMode: '当前模式',
|
||||||
absolute: '绝对定位',
|
absolute: '绝对定位',
|
||||||
relative: '相对定位',
|
relative: '相对定位',
|
||||||
connection: '连接',
|
|
||||||
channel: '通道',
|
channel: '通道',
|
||||||
networkError: '网络错误',
|
networkError: '网络错误',
|
||||||
disconnected: '已断开',
|
disconnected: '已断开',
|
||||||
availability: '可用性',
|
|
||||||
errorCode: '错误码',
|
|
||||||
hidUnavailable: 'HID不可用',
|
hidUnavailable: 'HID不可用',
|
||||||
sampleRate: '采样率',
|
sampleRate: '采样率',
|
||||||
channels: '声道',
|
channels: '声道',
|
||||||
@@ -892,6 +917,7 @@ export default {
|
|||||||
title: 'GOSTC 内网穿透',
|
title: 'GOSTC 内网穿透',
|
||||||
desc: '通过 GOSTC 实现内网穿透',
|
desc: '通过 GOSTC 实现内网穿透',
|
||||||
addr: '服务器地址',
|
addr: '服务器地址',
|
||||||
|
addrPlaceholder: '主机名或 IP(必填)',
|
||||||
key: '客户端密钥',
|
key: '客户端密钥',
|
||||||
tls: '启用 TLS',
|
tls: '启用 TLS',
|
||||||
},
|
},
|
||||||
@@ -918,9 +944,9 @@ export default {
|
|||||||
relayServerPlaceholder: 'hbbr.example.com:21117',
|
relayServerPlaceholder: 'hbbr.example.com:21117',
|
||||||
relayServerHint: '中继服务器地址(端口可省略,默认 21117),留空则自动从 ID 服务器推导',
|
relayServerHint: '中继服务器地址(端口可省略,默认 21117),留空则自动从 ID 服务器推导',
|
||||||
relayKey: '中继密钥',
|
relayKey: '中继密钥',
|
||||||
relayKeyPlaceholder: '输入中继服务器密钥',
|
relayKeyPlaceholder: '例如 pLU0pEj2IZnNVKzrIO1pIdwGA3dOVJJLkFIYGOCGH1E=',
|
||||||
relayKeySet: '••••••••',
|
relayKeySet: '已保存(32 字节 Base64,通常 44 字符;留空保存则保留)',
|
||||||
relayKeyHint: '中继服务器认证密钥(如果服务器使用 -k 选项)',
|
relayKeyHint: '与 hbbs/hbbr 的 -k 一致:标准 Base64,解码后固定 32 字节(一般为 44 个字符,含末尾 =)',
|
||||||
deviceInfo: '设备信息',
|
deviceInfo: '设备信息',
|
||||||
deviceId: '设备 ID',
|
deviceId: '设备 ID',
|
||||||
deviceIdHint: '此 ID 用于 RustDesk 客户端连接',
|
deviceIdHint: '此 ID 用于 RustDesk 客户端连接',
|
||||||
|
|||||||
15
web/src/lib/fps.ts
Normal file
15
web/src/lib/fps.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export function formatFpsValue(fps: number): string {
|
||||||
|
if (!Number.isFinite(fps)) return '0'
|
||||||
|
|
||||||
|
const rounded = Math.round(fps * 100) / 100
|
||||||
|
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(2).replace(/\.?0+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFpsLabel(fps: number): string {
|
||||||
|
return `${formatFpsValue(fps)} FPS`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toConfigFps(fps: number): number {
|
||||||
|
if (!Number.isFinite(fps)) return 30
|
||||||
|
return Math.round(fps)
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ const MJPEG_MODE_SUPPORTED_FORMATS = new Set([
|
|||||||
'YUYV',
|
'YUYV',
|
||||||
'YVYU',
|
'YVYU',
|
||||||
'NV12',
|
'NV12',
|
||||||
|
'NV16',
|
||||||
|
'NV24',
|
||||||
'RGB24',
|
'RGB24',
|
||||||
'BGR24',
|
'BGR24',
|
||||||
])
|
])
|
||||||
@@ -20,6 +22,7 @@ const CONFIG_SUPPORTED_FORMATS = new Set([
|
|||||||
'NV12',
|
'NV12',
|
||||||
'NV21',
|
'NV21',
|
||||||
'NV16',
|
'NV16',
|
||||||
|
'NV24',
|
||||||
'YUV420',
|
'YUV420',
|
||||||
'RGB24',
|
'RGB24',
|
||||||
'BGR24',
|
'BGR24',
|
||||||
@@ -32,6 +35,7 @@ const WEBRTC_SUPPORTED_FORMATS = new Set([
|
|||||||
'NV12',
|
'NV12',
|
||||||
'NV21',
|
'NV21',
|
||||||
'NV16',
|
'NV16',
|
||||||
|
'NV24',
|
||||||
'YUV420',
|
'YUV420',
|
||||||
'RGB24',
|
'RGB24',
|
||||||
'BGR24',
|
'BGR24',
|
||||||
@@ -45,14 +49,10 @@ function isCompressedFormat(formatName: string): boolean {
|
|||||||
return formatName === 'MJPEG' || formatName === 'JPEG'
|
return formatName === 'MJPEG' || formatName === 'JPEG'
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRkmppBackend(backendId?: string): boolean {
|
|
||||||
return backendId?.toLowerCase() === 'rkmpp'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getVideoFormatState(
|
export function getVideoFormatState(
|
||||||
formatName: string,
|
formatName: string,
|
||||||
context: VideoFormatSupportContext,
|
context: VideoFormatSupportContext,
|
||||||
encoderBackend = 'auto',
|
_encoderBackend = 'auto',
|
||||||
): VideoFormatState {
|
): VideoFormatState {
|
||||||
const normalizedFormat = normalizeFormat(formatName)
|
const normalizedFormat = normalizeFormat(formatName)
|
||||||
|
|
||||||
@@ -64,12 +64,6 @@ export function getVideoFormatState(
|
|||||||
if (CONFIG_SUPPORTED_FORMATS.has(normalizedFormat)) {
|
if (CONFIG_SUPPORTED_FORMATS.has(normalizedFormat)) {
|
||||||
return 'supported'
|
return 'supported'
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
normalizedFormat === 'NV24'
|
|
||||||
&& isRkmppBackend(encoderBackend)
|
|
||||||
) {
|
|
||||||
return 'supported'
|
|
||||||
}
|
|
||||||
return 'unsupported'
|
return 'unsupported'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,14 +71,6 @@ export function getVideoFormatState(
|
|||||||
return isCompressedFormat(normalizedFormat) ? 'not_recommended' : 'supported'
|
return isCompressedFormat(normalizedFormat) ? 'not_recommended' : 'supported'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
normalizedFormat === 'NV24'
|
|
||||||
&& isRkmppBackend(encoderBackend)
|
|
||||||
&& (context === 'h264' || context === 'h265')
|
|
||||||
) {
|
|
||||||
return 'supported'
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'unsupported'
|
return 'unsupported'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ import type {
|
|||||||
StreamConfigUpdate,
|
StreamConfigUpdate,
|
||||||
VideoConfig,
|
VideoConfig,
|
||||||
VideoConfigUpdate,
|
VideoConfigUpdate,
|
||||||
WebConfig,
|
|
||||||
WebConfigUpdate,
|
|
||||||
} from '@/types/generated'
|
} from '@/types/generated'
|
||||||
import type {
|
import type {
|
||||||
RtspConfigResponse as ApiRtspConfigResponse,
|
RtspConfigResponse as ApiRtspConfigResponse,
|
||||||
@@ -38,6 +36,8 @@ import type {
|
|||||||
RustDeskConfigUpdate as ApiRustDeskConfigUpdate,
|
RustDeskConfigUpdate as ApiRustDeskConfigUpdate,
|
||||||
RustDeskStatusResponse as ApiRustDeskStatusResponse,
|
RustDeskStatusResponse as ApiRustDeskStatusResponse,
|
||||||
RustDeskPasswordResponse as ApiRustDeskPasswordResponse,
|
RustDeskPasswordResponse as ApiRustDeskPasswordResponse,
|
||||||
|
WebConfig,
|
||||||
|
WebConfigUpdate,
|
||||||
} from '@/api'
|
} from '@/api'
|
||||||
|
|
||||||
function normalizeErrorMessage(error: unknown): string {
|
function normalizeErrorMessage(error: unknown): string {
|
||||||
|
|||||||
@@ -259,6 +259,30 @@ export const useSystemStore = defineStore('system', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchStreamState() {
|
||||||
|
try {
|
||||||
|
const [status, modeResp] = await Promise.all([
|
||||||
|
streamApi.status(),
|
||||||
|
streamApi.getMode().catch(() => ({ mode: 'mjpeg' }))
|
||||||
|
])
|
||||||
|
stream.value = {
|
||||||
|
online: status.state === 'streaming',
|
||||||
|
active: status.state !== 'uninitialized',
|
||||||
|
device: status.device,
|
||||||
|
format: status.format,
|
||||||
|
resolution: status.resolution,
|
||||||
|
targetFps: status.target_fps,
|
||||||
|
clients: status.clients,
|
||||||
|
streamMode: modeResp.mode || 'mjpeg',
|
||||||
|
error: status.state === 'error' ? 'Stream error' : null,
|
||||||
|
}
|
||||||
|
return status
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch stream state:', e)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchAllStates() {
|
async function fetchAllStates() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
@@ -266,7 +290,8 @@ export const useSystemStore = defineStore('system', () => {
|
|||||||
try {
|
try {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fetchSystemInfo(),
|
fetchSystemInfo(),
|
||||||
// HID state is updated via WebSocket device_info event
|
fetchStreamState().catch(() => null),
|
||||||
|
fetchHidState().catch(() => null),
|
||||||
fetchAtxState().catch(() => null),
|
fetchAtxState().catch(() => null),
|
||||||
fetchMsdState().catch(() => null),
|
fetchMsdState().catch(() => null),
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -289,7 +289,11 @@ export interface StreamConfig {
|
|||||||
turn_password?: string;
|
turn_password?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Web server configuration */
|
/**
|
||||||
|
* Web server configuration persisted in the database (includes on-disk TLS paths).
|
||||||
|
*
|
||||||
|
* The HTTP API for `/api/config/web` uses `WebConfigResponse` instead: no path fields, includes `has_custom_cert`.
|
||||||
|
*/
|
||||||
export interface WebConfig {
|
export interface WebConfig {
|
||||||
/** HTTP port */
|
/** HTTP port */
|
||||||
http_port: number;
|
http_port: number;
|
||||||
@@ -321,7 +325,7 @@ export interface TtydConfig {
|
|||||||
export interface GostcConfig {
|
export interface GostcConfig {
|
||||||
/** Enable auto-start */
|
/** Enable auto-start */
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
/** Server address (e.g., gostc.mofeng.run) */
|
/** Server address (hostname or IP) */
|
||||||
addr: string;
|
addr: string;
|
||||||
/** Client key from GOSTC management panel */
|
/** Client key from GOSTC management panel */
|
||||||
key: string;
|
key: string;
|
||||||
@@ -686,12 +690,33 @@ export interface VideoConfigUpdate {
|
|||||||
quality?: number;
|
quality?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Web server settings returned by `GET` / `PATCH /api/config/web`.
|
||||||
|
*
|
||||||
|
* Public API shape: certificate paths on disk are not exposed. The full stored model is `WebConfig` in `config::schema`.
|
||||||
|
*/
|
||||||
|
export interface WebConfigResponse {
|
||||||
|
http_port: number;
|
||||||
|
https_port: number;
|
||||||
|
bind_addresses: string[];
|
||||||
|
bind_address: string;
|
||||||
|
https_enabled: boolean;
|
||||||
|
/** Whether a custom TLS certificate is active (non-empty cert + key paths in stored config). */
|
||||||
|
has_custom_cert: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WebConfigUpdate {
|
export interface WebConfigUpdate {
|
||||||
http_port?: number;
|
http_port?: number;
|
||||||
https_port?: number;
|
https_port?: number;
|
||||||
bind_addresses?: string[];
|
bind_addresses?: string[];
|
||||||
bind_address?: string;
|
bind_address?: string;
|
||||||
https_enabled?: boolean;
|
https_enabled?: boolean;
|
||||||
|
/** PEM-encoded certificate content (must be provided together with ssl_key_pem) */
|
||||||
|
ssl_cert_pem?: string;
|
||||||
|
/** PEM-encoded private key content (must be provided together with ssl_cert_pem) */
|
||||||
|
ssl_key_pem?: string;
|
||||||
|
/** Set to true to remove the custom certificate and revert to self-signed */
|
||||||
|
clear_custom_cert?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import type { HidKeyboardEvent, HidMouseEvent } from '@/types/hid'
|
|||||||
import { keyboardEventToCanonicalKey, updateModifierMaskForKey } from '@/lib/keyboardMappings'
|
import { keyboardEventToCanonicalKey, updateModifierMaskForKey } from '@/lib/keyboardMappings'
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
import { generateUUID } from '@/lib/utils'
|
import { generateUUID } from '@/lib/utils'
|
||||||
|
import { formatFpsValue } from '@/lib/fps'
|
||||||
import type { VideoMode } from '@/components/VideoConfigPopover.vue'
|
import type { VideoMode } from '@/components/VideoConfigPopover.vue'
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
@@ -25,6 +26,8 @@ import ActionBar from '@/components/ActionBar.vue'
|
|||||||
import InfoBar from '@/components/InfoBar.vue'
|
import InfoBar from '@/components/InfoBar.vue'
|
||||||
import VirtualKeyboard from '@/components/VirtualKeyboard.vue'
|
import VirtualKeyboard from '@/components/VirtualKeyboard.vue'
|
||||||
import StatsSheet from '@/components/StatsSheet.vue'
|
import StatsSheet from '@/components/StatsSheet.vue'
|
||||||
|
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
|
||||||
|
import BrandMark from '@/components/BrandMark.vue'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Spinner } from '@/components/ui/spinner'
|
import { Spinner } from '@/components/ui/spinner'
|
||||||
import {
|
import {
|
||||||
@@ -44,22 +47,19 @@ import {
|
|||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import {
|
import {
|
||||||
Monitor,
|
|
||||||
MonitorOff,
|
MonitorOff,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
LogOut,
|
LogOut,
|
||||||
Sun,
|
Sun,
|
||||||
Moon,
|
Moon,
|
||||||
Languages,
|
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Terminal,
|
Terminal,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
Loader2,
|
Loader2,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
import { setLanguage } from '@/i18n'
|
|
||||||
|
|
||||||
const { t, locale } = useI18n()
|
const { t } = useI18n()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const systemStore = useSystemStore()
|
const systemStore = useSystemStore()
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
@@ -96,6 +96,7 @@ const videoLoading = ref(true)
|
|||||||
const videoError = ref(false)
|
const videoError = ref(false)
|
||||||
const videoErrorMessage = ref('')
|
const videoErrorMessage = ref('')
|
||||||
const videoRestarting = ref(false) // Track if video is restarting due to config change
|
const videoRestarting = ref(false) // Track if video is restarting due to config change
|
||||||
|
const mjpegFrameReceived = ref(false) // Whether MJPEG stream has received at least one frame
|
||||||
|
|
||||||
// Video aspect ratio (dynamically updated from actual video dimensions)
|
// Video aspect ratio (dynamically updated from actual video dimensions)
|
||||||
// Using string format "width/height" to let browser handle the ratio calculation
|
// Using string format "width/height" to let browser handle the ratio calculation
|
||||||
@@ -188,6 +189,11 @@ const videoStatus = computed<'connected' | 'connecting' | 'disconnected' | 'erro
|
|||||||
if (webrtc.isConnecting.value) return 'connecting'
|
if (webrtc.isConnecting.value) return 'connecting'
|
||||||
if (webrtc.isConnected.value) return 'connected'
|
if (webrtc.isConnected.value) return 'connected'
|
||||||
}
|
}
|
||||||
|
// MJPEG: check if frames have actually arrived (frontend-side detection)
|
||||||
|
// This is more reliable than relying on stream.online from backend,
|
||||||
|
// which can be stale due to the debounce delay in device_info broadcaster.
|
||||||
|
// Also handles browsers that don't fire img.onload for multipart MJPEG streams.
|
||||||
|
if (videoMode.value === 'mjpeg' && mjpegFrameReceived.value) return 'connected'
|
||||||
if (systemStore.stream?.online) return 'connected'
|
if (systemStore.stream?.online) return 'connected'
|
||||||
return 'disconnected'
|
return 'disconnected'
|
||||||
})
|
})
|
||||||
@@ -212,31 +218,29 @@ const videoQuickInfo = computed(() => {
|
|||||||
const stream = systemStore.stream
|
const stream = systemStore.stream
|
||||||
if (!stream?.resolution) return ''
|
if (!stream?.resolution) return ''
|
||||||
const resShort = getResolutionShortName(stream.resolution[0], stream.resolution[1])
|
const resShort = getResolutionShortName(stream.resolution[0], stream.resolution[1])
|
||||||
return `${resShort} ${backendFps.value}fps`
|
return `${resShort} ${formatFpsValue(backendFps.value)}fps`
|
||||||
})
|
})
|
||||||
|
|
||||||
const videoDetails = computed<StatusDetail[]>(() => {
|
const videoDetails = computed<StatusDetail[]>(() => {
|
||||||
const stream = systemStore.stream
|
const stream = systemStore.stream
|
||||||
if (!stream) return []
|
if (!stream) return []
|
||||||
// Use backend-provided FPS from WebSocket
|
|
||||||
const receivedFps = backendFps.value
|
const receivedFps = backendFps.value
|
||||||
// Display mode: use local videoMode which is synced with server
|
|
||||||
const modeDisplay = videoMode.value === 'mjpeg' ? 'MJPEG' : `${videoMode.value.toUpperCase()} (WebRTC)`
|
// Input (capture) format → output (delivery) mode
|
||||||
const details: StatusDetail[] = [
|
const inputFmt = stream.format || 'MJPEG'
|
||||||
|
const outputFmt = videoMode.value === 'mjpeg' ? 'MJPEG' : `${videoMode.value.toUpperCase()} (WebRTC)`
|
||||||
|
const formatDisplay = inputFmt === outputFmt ? inputFmt : `${inputFmt} → ${outputFmt}`
|
||||||
|
|
||||||
|
// Target / actual FPS combined
|
||||||
|
const fpsDisplay = `${formatFpsValue(stream.targetFps ?? 0)} / ${formatFpsValue(receivedFps)}`
|
||||||
|
const fpsStatus: StatusDetail['status'] = receivedFps > 5 ? 'ok' : receivedFps > 0 ? 'warning' : undefined
|
||||||
|
|
||||||
|
return [
|
||||||
{ label: t('statusCard.device'), value: stream.device || '-' },
|
{ label: t('statusCard.device'), value: stream.device || '-' },
|
||||||
{ label: t('statusCard.mode'), value: modeDisplay, status: 'ok' },
|
{ label: t('statusCard.format'), value: formatDisplay },
|
||||||
{ label: t('statusCard.format'), value: stream.format || 'MJPEG' },
|
|
||||||
{ label: t('statusCard.resolution'), value: stream.resolution ? `${stream.resolution[0]}x${stream.resolution[1]}` : '-' },
|
{ label: t('statusCard.resolution'), value: stream.resolution ? `${stream.resolution[0]}x${stream.resolution[1]}` : '-' },
|
||||||
{ label: t('statusCard.targetFps'), value: String(stream.targetFps ?? 0) },
|
{ label: t('statusCard.fps'), value: fpsDisplay, status: fpsStatus },
|
||||||
{ label: t('statusCard.fps'), value: String(receivedFps), status: receivedFps > 5 ? 'ok' : receivedFps > 0 ? 'warning' : undefined },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
// Show network error if WebSocket has network issue
|
|
||||||
if (wsNetworkError.value) {
|
|
||||||
details.push({ label: t('statusCard.connection'), value: t('statusCard.networkError'), status: 'warning' })
|
|
||||||
}
|
|
||||||
|
|
||||||
return details
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const hidStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
|
const hidStatus = computed<'connected' | 'connecting' | 'disconnected' | 'error'>(() => {
|
||||||
@@ -352,54 +356,59 @@ const hidDetails = computed<StatusDetail[]>(() => {
|
|||||||
const hidErrorStatus: StatusDetail['status'] =
|
const hidErrorStatus: StatusDetail['status'] =
|
||||||
hid.errorCode === 'udc_not_configured' ? 'warning' : 'error'
|
hid.errorCode === 'udc_not_configured' ? 'warning' : 'error'
|
||||||
|
|
||||||
const details: StatusDetail[] = [
|
const details: StatusDetail[] = []
|
||||||
{ label: t('statusCard.device'), value: hid.device || '-' },
|
|
||||||
{ label: t('statusCard.backend'), value: hid.backend || t('common.unknown') },
|
|
||||||
{ label: t('statusCard.initialized'), value: hid.initialized ? t('statusCard.yes') : t('statusCard.no'), status: hid.error && hid.errorCode !== 'udc_not_configured' ? 'error' : hid.initialized ? 'ok' : 'warning' },
|
|
||||||
{ label: t('statusCard.online'), value: hid.online ? t('statusCard.yes') : t('statusCard.no'), status: hid.online ? 'ok' : hid.initialized ? 'warning' : 'error' },
|
|
||||||
{ label: t('statusCard.currentMode'), value: mouseMode.value === 'absolute' ? t('statusCard.absolute') : t('statusCard.relative'), status: 'ok' },
|
|
||||||
{
|
|
||||||
label: t('settings.otgKeyboardLeds'),
|
|
||||||
value: hid.keyboardLedsEnabled
|
|
||||||
? `Caps:${hid.ledState.capsLock ? t('common.on') : t('common.off')} Num:${hid.ledState.numLock ? t('common.on') : t('common.off')} Scroll:${hid.ledState.scrollLock ? t('common.on') : t('common.off')}`
|
|
||||||
: t('infobar.keyboardLedUnavailable'),
|
|
||||||
status: hid.keyboardLedsEnabled ? 'ok' : undefined,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
if (hid.errorCode) {
|
// Backend + device combined
|
||||||
details.push({ label: t('statusCard.errorCode'), value: hid.errorCode, status: hidErrorStatus })
|
const backendStr = hid.backend || t('common.unknown')
|
||||||
}
|
const deviceStr = hid.device ? ` @ ${hid.device}` : ''
|
||||||
|
details.push({ label: t('statusCard.backend'), value: `${backendStr}${deviceStr}` })
|
||||||
|
|
||||||
|
// Error message (with error code as suffix when present) OR normal-state info
|
||||||
if (errorMessage) {
|
if (errorMessage) {
|
||||||
details.push({ label: t('common.error'), value: errorMessage, status: hidErrorStatus })
|
const codeSuffix = hid.errorCode ? ` (${hid.errorCode})` : ''
|
||||||
|
details.push({ label: t('common.error'), value: `${errorMessage}${codeSuffix}`, status: hidErrorStatus })
|
||||||
|
} else if (hid.online) {
|
||||||
|
details.push({ label: t('statusCard.currentMode'), value: mouseMode.value === 'absolute' ? t('statusCard.absolute') : t('statusCard.relative'), status: 'ok' })
|
||||||
|
if (hid.keyboardLedsEnabled) {
|
||||||
|
details.push({
|
||||||
|
label: t('settings.otgKeyboardLeds'),
|
||||||
|
value: `Caps:${hid.ledState.capsLock ? t('common.on') : t('common.off')} Num:${hid.ledState.numLock ? t('common.on') : t('common.off')} Scroll:${hid.ledState.scrollLock ? t('common.on') : t('common.off')}`,
|
||||||
|
status: 'ok',
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add HID channel info based on video mode
|
// Channel (merged with availability / connection state)
|
||||||
|
let channelValue: string
|
||||||
|
let channelStatus: StatusDetail['status']
|
||||||
if (videoMode.value !== 'mjpeg') {
|
if (videoMode.value !== 'mjpeg') {
|
||||||
// WebRTC mode - show DataChannel status
|
|
||||||
if (webrtc.dataChannelReady.value) {
|
if (webrtc.dataChannelReady.value) {
|
||||||
details.push({ label: t('statusCard.channel'), value: 'DataChannel (WebRTC)', status: 'ok' })
|
channelValue = 'DataChannel (WebRTC)'
|
||||||
|
channelStatus = 'ok'
|
||||||
} else if (webrtc.isConnecting.value || webrtc.isConnected.value) {
|
} else if (webrtc.isConnecting.value || webrtc.isConnected.value) {
|
||||||
details.push({ label: t('statusCard.channel'), value: 'DataChannel', status: 'warning' })
|
channelValue = 'DataChannel'
|
||||||
|
channelStatus = 'warning'
|
||||||
} else {
|
} else {
|
||||||
// Fallback to WebSocket
|
channelValue = 'WebSocket (fallback)'
|
||||||
details.push({ label: t('statusCard.channel'), value: 'WebSocket (fallback)', status: hidWs.connected.value ? 'ok' : 'warning' })
|
channelStatus = hidWs.connected.value ? 'ok' : 'warning'
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// MJPEG mode - WebSocket HID
|
channelValue = 'WebSocket'
|
||||||
details.push({ label: t('statusCard.channel'), value: 'WebSocket', status: hidWs.connected.value ? 'ok' : 'warning' })
|
channelStatus = hidWs.connected.value ? 'ok' : 'warning'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add connection status for WebSocket (only relevant for MJPEG or fallback)
|
|
||||||
if (videoMode.value === 'mjpeg' || !webrtc.dataChannelReady.value) {
|
if (videoMode.value === 'mjpeg' || !webrtc.dataChannelReady.value) {
|
||||||
if (hidWs.networkError.value) {
|
if (hidWs.networkError.value) {
|
||||||
details.push({ label: t('statusCard.connection'), value: t('statusCard.networkError'), status: 'warning' })
|
channelValue += ` (${t('statusCard.networkError')})`
|
||||||
|
channelStatus = 'warning'
|
||||||
} else if (!hidWs.connected.value) {
|
} else if (!hidWs.connected.value) {
|
||||||
details.push({ label: t('statusCard.connection'), value: t('statusCard.disconnected'), status: 'warning' })
|
channelValue += ` (${t('statusCard.disconnected')})`
|
||||||
|
channelStatus = 'warning'
|
||||||
} else if (hidWs.hidUnavailable.value) {
|
} else if (hidWs.hidUnavailable.value) {
|
||||||
details.push({ label: t('statusCard.availability'), value: t('statusCard.hidUnavailable'), status: 'warning' })
|
channelValue += ` (${t('statusCard.hidUnavailable')})`
|
||||||
|
channelStatus = 'warning'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
details.push({ label: t('statusCard.channel'), value: channelValue, status: channelStatus })
|
||||||
|
|
||||||
return details
|
return details
|
||||||
})
|
})
|
||||||
@@ -561,6 +570,12 @@ const MAX_CONSECUTIVE_ERRORS = 2 // If 2+ errors in grace period, it's a real pr
|
|||||||
let pendingWebRTCReadyGate = false
|
let pendingWebRTCReadyGate = false
|
||||||
let webrtcConnectTask: Promise<boolean> | null = null
|
let webrtcConnectTask: Promise<boolean> | null = null
|
||||||
|
|
||||||
|
// WebRTC auto-reconnect on device-lost/recovery
|
||||||
|
let webrtcRecoveryTimerId: number | null = null
|
||||||
|
let webrtcRecoveryAttempts = 0
|
||||||
|
const MAX_WEBRTC_RECOVERY_ATTEMPTS = 8
|
||||||
|
const WEBRTC_RECOVERY_BASE_DELAY = 2000
|
||||||
|
|
||||||
// Last-frame overlay (prevents black flash during mode switches)
|
// Last-frame overlay (prevents black flash during mode switches)
|
||||||
const frameOverlayUrl = ref<string | null>(null)
|
const frameOverlayUrl = ref<string | null>(null)
|
||||||
|
|
||||||
@@ -680,6 +695,7 @@ function handleVideoLoad() {
|
|||||||
// MJPEG video frame loaded successfully - update stream online status
|
// MJPEG video frame loaded successfully - update stream online status
|
||||||
// This fixes the timing issue where device_info event may arrive before stream is fully active
|
// This fixes the timing issue where device_info event may arrive before stream is fully active
|
||||||
if (videoMode.value === 'mjpeg') {
|
if (videoMode.value === 'mjpeg') {
|
||||||
|
mjpegFrameReceived.value = true
|
||||||
systemStore.setStreamOnline(true)
|
systemStore.setStreamOnline(true)
|
||||||
// Update aspect ratio from MJPEG image dimensions
|
// Update aspect ratio from MJPEG image dimensions
|
||||||
const img = videoRef.value
|
const img = videoRef.value
|
||||||
@@ -758,6 +774,7 @@ function handleVideoError() {
|
|||||||
|
|
||||||
// Show loading state immediately
|
// Show loading state immediately
|
||||||
videoLoading.value = true
|
videoLoading.value = true
|
||||||
|
mjpegFrameReceived.value = false
|
||||||
|
|
||||||
// Auto-retry with exponential backoff (infinite retry, capped delay)
|
// Auto-retry with exponential backoff (infinite retry, capped delay)
|
||||||
retryCount++
|
retryCount++
|
||||||
@@ -773,9 +790,78 @@ function handleVideoError() {
|
|||||||
function handleStreamDeviceLost(data: { device: string; reason: string }) {
|
function handleStreamDeviceLost(data: { device: string; reason: string }) {
|
||||||
videoError.value = true
|
videoError.value = true
|
||||||
videoErrorMessage.value = t('console.deviceLostDesc', { device: data.device, reason: data.reason })
|
videoErrorMessage.value = t('console.deviceLostDesc', { device: data.device, reason: data.reason })
|
||||||
|
|
||||||
|
// In WebRTC mode, the pipeline will attempt to restart itself.
|
||||||
|
// Start an exponential-backoff reconnect loop so the session is
|
||||||
|
// re-established automatically once the backend is ready again.
|
||||||
|
if (videoMode.value !== 'mjpeg') {
|
||||||
|
scheduleWebRTCRecovery()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleWebRTCRecovery() {
|
||||||
|
// Clear any previous timer
|
||||||
|
if (webrtcRecoveryTimerId !== null) {
|
||||||
|
clearTimeout(webrtcRecoveryTimerId)
|
||||||
|
webrtcRecoveryTimerId = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (webrtcRecoveryAttempts >= MAX_WEBRTC_RECOVERY_ATTEMPTS) {
|
||||||
|
console.warn('[Recovery] Max WebRTC recovery attempts reached, giving up')
|
||||||
|
webrtcRecoveryAttempts = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = Math.min(
|
||||||
|
WEBRTC_RECOVERY_BASE_DELAY * Math.pow(2, webrtcRecoveryAttempts),
|
||||||
|
30000,
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[Recovery] Scheduling WebRTC reconnect attempt ${webrtcRecoveryAttempts + 1}/${MAX_WEBRTC_RECOVERY_ATTEMPTS} in ${delay}ms`,
|
||||||
|
)
|
||||||
|
|
||||||
|
webrtcRecoveryTimerId = window.setTimeout(async () => {
|
||||||
|
webrtcRecoveryTimerId = null
|
||||||
|
webrtcRecoveryAttempts++
|
||||||
|
|
||||||
|
// Only reconnect if we are still in a WebRTC mode and error state
|
||||||
|
if (videoMode.value === 'mjpeg' || !videoError.value) {
|
||||||
|
webrtcRecoveryAttempts = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Recovery] Attempting WebRTC reconnect (attempt ${webrtcRecoveryAttempts})`)
|
||||||
|
try {
|
||||||
|
await webrtc.disconnect()
|
||||||
|
const ok = await connectWebRTCSerial('device-recovery')
|
||||||
|
if (ok) {
|
||||||
|
console.log('[Recovery] WebRTC reconnected successfully')
|
||||||
|
videoError.value = false
|
||||||
|
videoErrorMessage.value = ''
|
||||||
|
webrtcRecoveryAttempts = 0
|
||||||
|
} else {
|
||||||
|
// Retry
|
||||||
|
scheduleWebRTCRecovery()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
scheduleWebRTCRecovery()
|
||||||
|
}
|
||||||
|
}, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelWebRTCRecovery() {
|
||||||
|
if (webrtcRecoveryTimerId !== null) {
|
||||||
|
clearTimeout(webrtcRecoveryTimerId)
|
||||||
|
webrtcRecoveryTimerId = null
|
||||||
|
}
|
||||||
|
webrtcRecoveryAttempts = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleStreamRecovered(_data: { device: string }) {
|
function handleStreamRecovered(_data: { device: string }) {
|
||||||
|
// Cancel any pending recovery timer – backend is back
|
||||||
|
cancelWebRTCRecovery()
|
||||||
|
|
||||||
// Reset video error state
|
// Reset video error state
|
||||||
videoError.value = false
|
videoError.value = false
|
||||||
videoErrorMessage.value = ''
|
videoErrorMessage.value = ''
|
||||||
@@ -875,7 +961,7 @@ async function handleStreamConfigApplied(data: any) {
|
|||||||
videoRestarting.value = false
|
videoRestarting.value = false
|
||||||
|
|
||||||
toast.success(t('console.videoRestarted'), {
|
toast.success(t('console.videoRestarted'), {
|
||||||
description: `${data.device} - ${data.resolution[0]}x${data.resolution[1]} @ ${data.fps}fps`,
|
description: `${data.device} - ${data.resolution[0]}x${data.resolution[1]} @ ${formatFpsValue(data.fps)}fps`,
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -910,6 +996,16 @@ function handleStreamStateChanged(data: any) {
|
|||||||
if (data.state === 'error') {
|
if (data.state === 'error') {
|
||||||
videoError.value = true
|
videoError.value = true
|
||||||
videoErrorMessage.value = t('console.streamError')
|
videoErrorMessage.value = t('console.streamError')
|
||||||
|
} else if (data.state === 'recovering' && videoMode.value !== 'mjpeg') {
|
||||||
|
// Backend is in the DeviceLost recovery loop; start WebRTC reconnect if not already scheduled.
|
||||||
|
if (webrtcRecoveryTimerId === null && webrtcRecoveryAttempts === 0) {
|
||||||
|
scheduleWebRTCRecovery()
|
||||||
|
}
|
||||||
|
} else if (data.state === 'streaming' || data.state === 'no_signal') {
|
||||||
|
// Backend stream is alive; cancel any pending recovery timers.
|
||||||
|
if (data.state === 'streaming') {
|
||||||
|
cancelWebRTCRecovery()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1062,6 +1158,7 @@ function refreshVideo() {
|
|||||||
backendFps.value = 0
|
backendFps.value = 0
|
||||||
videoError.value = false
|
videoError.value = false
|
||||||
videoErrorMessage.value = ''
|
videoErrorMessage.value = ''
|
||||||
|
mjpegFrameReceived.value = false
|
||||||
|
|
||||||
// Update timestamp to force MJPEG reconnection via reactive URL
|
// Update timestamp to force MJPEG reconnection via reactive URL
|
||||||
isRefreshingVideo = true
|
isRefreshingVideo = true
|
||||||
@@ -1218,18 +1315,17 @@ async function switchToWebRTC(codec: VideoMode = 'h264') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Connect WebRTC with retry
|
// Step 3: Connect WebRTC with retry (backoff between retries)
|
||||||
let retries = 3
|
const MAX_ATTEMPTS = 3
|
||||||
|
const RETRY_DELAYS = [200, 800]
|
||||||
let success = false
|
let success = false
|
||||||
while (retries > 0 && !success) {
|
for (let attempt = 0; attempt < MAX_ATTEMPTS && !success; attempt++) {
|
||||||
|
if (attempt > 0) {
|
||||||
|
const delay = RETRY_DELAYS[attempt - 1] ?? RETRY_DELAYS[RETRY_DELAYS.length - 1]
|
||||||
|
console.log(`[WebRTC] Connection failed, retrying in ${delay}ms (${MAX_ATTEMPTS - attempt} attempts left)`)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delay))
|
||||||
|
}
|
||||||
success = await connectWebRTCSerial('switchToWebRTC')
|
success = await connectWebRTCSerial('switchToWebRTC')
|
||||||
if (!success) {
|
|
||||||
retries--
|
|
||||||
if (retries > 0) {
|
|
||||||
console.log(`[WebRTC] Connection failed, retrying (${retries} attempts left)`)
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 500))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (success) {
|
if (success) {
|
||||||
toast.success(t('console.webrtcConnected'), {
|
toast.success(t('console.webrtcConnected'), {
|
||||||
@@ -1432,10 +1528,22 @@ watch(() => webrtc.state.value, (newState, oldState) => {
|
|||||||
}, 1000)
|
}, 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle direct 'failed' state (ICE or DTLS failure)
|
||||||
|
// Allow one automatic retry before marking as failed, consistent with
|
||||||
|
// the disconnected->reconnect path that allows 2 failures.
|
||||||
if (newState === 'failed' && videoMode.value !== 'mjpeg') {
|
if (newState === 'failed' && videoMode.value !== 'mjpeg') {
|
||||||
webrtcReconnectFailures += 1
|
webrtcReconnectFailures += 1
|
||||||
if (webrtcReconnectFailures >= 1) {
|
if (webrtcReconnectFailures >= 2) {
|
||||||
markWebRTCFailure(t('console.webrtcFailed'))
|
markWebRTCFailure(t('console.webrtcFailed'))
|
||||||
|
} else {
|
||||||
|
webrtcReconnectTimeout = setTimeout(async () => {
|
||||||
|
if (videoMode.value !== 'mjpeg' && webrtc.state.value !== 'connected') {
|
||||||
|
const success = await connectWebRTCSerial('auto reconnect after failed')
|
||||||
|
if (!success) {
|
||||||
|
markWebRTCFailure(t('console.webrtcFailed'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -1458,12 +1566,6 @@ function toggleTheme() {
|
|||||||
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
|
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Language toggle
|
|
||||||
function toggleLanguage() {
|
|
||||||
const newLang = locale.value === 'zh-CN' ? 'en-US' : 'zh-CN'
|
|
||||||
setLanguage(newLang)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Logout
|
// Logout
|
||||||
async function logout() {
|
async function logout() {
|
||||||
await authStore.logout()
|
await authStore.logout()
|
||||||
@@ -2067,6 +2169,15 @@ async function activateConsoleView() {
|
|||||||
isConsoleActive.value = true
|
isConsoleActive.value = true
|
||||||
registerInteractionListeners()
|
registerInteractionListeners()
|
||||||
|
|
||||||
|
// REST snapshot: returning from Settings (or other routes) may have missed WS device_info
|
||||||
|
void systemStore.fetchAllStates()
|
||||||
|
void configStore.refreshHid().then(() => syncMouseModeFromConfig()).catch(() => {})
|
||||||
|
|
||||||
|
// Ensure HID WebSocket is connected when console becomes active
|
||||||
|
if (!hidWs.connected.value) {
|
||||||
|
hidWs.connect().catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
if (videoMode.value !== 'mjpeg' && webrtc.videoTrack.value) {
|
if (videoMode.value !== 'mjpeg' && webrtc.videoTrack.value) {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
await rebindWebRTCVideo()
|
await rebindWebRTCVideo()
|
||||||
@@ -2216,6 +2327,7 @@ onUnmounted(() => {
|
|||||||
clearTimeout(gracePeriodTimeoutId)
|
clearTimeout(gracePeriodTimeoutId)
|
||||||
gracePeriodTimeoutId = null
|
gracePeriodTimeoutId = null
|
||||||
}
|
}
|
||||||
|
cancelWebRTCRecovery()
|
||||||
videoSession.clearWaiters()
|
videoSession.clearWaiters()
|
||||||
|
|
||||||
// Reset counters
|
// Reset counters
|
||||||
@@ -2238,18 +2350,42 @@ onUnmounted(() => {
|
|||||||
<div class="h-screen h-dvh flex flex-col bg-background">
|
<div class="h-screen h-dvh flex flex-col bg-background">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="shrink-0 border-b border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
|
<header class="shrink-0 border-b border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
|
||||||
<div class="px-4">
|
<div class="px-2 sm:px-4">
|
||||||
<div class="h-14 flex items-center justify-between">
|
<div class="h-10 sm:h-14 flex items-center justify-between">
|
||||||
<!-- Left: Logo -->
|
<!-- Left: Logo -->
|
||||||
<div class="flex items-center gap-6">
|
<div class="flex items-center gap-2 sm:gap-6">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-1.5 sm:gap-2">
|
||||||
<Monitor class="h-6 w-6 text-primary" />
|
<BrandMark size="md" class="hidden sm:block" />
|
||||||
<span class="font-bold text-lg">One-KVM</span>
|
<BrandMark size="sm" class="sm:hidden" />
|
||||||
|
<span class="font-bold text-sm sm:text-lg">One-KVM</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile Status Indicators (inline, minimal) -->
|
||||||
|
<div class="flex md:hidden items-center gap-1">
|
||||||
|
<StatusCard
|
||||||
|
:title="t('statusCard.video')"
|
||||||
|
type="video"
|
||||||
|
:status="videoStatus"
|
||||||
|
:quick-info="videoQuickInfo"
|
||||||
|
:error-message="videoErrorMessage"
|
||||||
|
:details="videoDetails"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
|
||||||
|
<StatusCard
|
||||||
|
:title="t('statusCard.hid')"
|
||||||
|
type="hid"
|
||||||
|
:status="hidStatus"
|
||||||
|
:quick-info="hidQuickInfo"
|
||||||
|
:details="hidDetails"
|
||||||
|
:hover-align="hidHoverAlign"
|
||||||
|
compact
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right: Status Cards + User Menu -->
|
<!-- Right: Status Cards + User Menu -->
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-1 sm:gap-2">
|
||||||
<div class="hidden md:flex items-center gap-2">
|
<div class="hidden md:flex items-center gap-2">
|
||||||
<!-- Video Status -->
|
<!-- Video Status -->
|
||||||
<StatusCard
|
<StatusCard
|
||||||
@@ -2306,16 +2442,14 @@ onUnmounted(() => {
|
|||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<!-- Language Toggle -->
|
<!-- Language Toggle -->
|
||||||
<Button variant="ghost" size="icon" class="h-8 w-8 hidden md:flex" :aria-label="t('common.toggleLanguage')" @click="toggleLanguage">
|
<LanguageToggleButton class="h-8 w-8 hidden md:flex" />
|
||||||
<Languages class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<!-- User Menu -->
|
<!-- User Menu -->
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger as-child>
|
<DropdownMenuTrigger as-child>
|
||||||
<Button variant="outline" size="sm" class="gap-1.5">
|
<Button variant="outline" size="sm" class="gap-1 sm:gap-1.5 h-7 sm:h-9 px-2 sm:px-3">
|
||||||
<span class="text-xs max-w-[100px] truncate">{{ authStore.user || 'admin' }}</span>
|
<span class="text-xs max-w-[60px] sm:max-w-[100px] truncate">{{ authStore.user || 'admin' }}</span>
|
||||||
<ChevronDown class="h-3.5 w-3.5" />
|
<ChevronDown class="h-3 w-3 sm:h-3.5 sm:w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
@@ -2324,9 +2458,13 @@ onUnmounted(() => {
|
|||||||
<Moon v-else class="h-4 w-4 mr-2" />
|
<Moon v-else class="h-4 w-4 mr-2" />
|
||||||
{{ isDark ? t('settings.lightMode') : t('settings.darkMode') }}
|
{{ isDark ? t('settings.lightMode') : t('settings.darkMode') }}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem class="md:hidden" @click="toggleLanguage">
|
<DropdownMenuItem as-child class="md:hidden p-0">
|
||||||
<Languages class="h-4 w-4 mr-2" />
|
<LanguageToggleButton
|
||||||
{{ locale === 'zh-CN' ? 'English' : '中文' }}
|
label-mode="next"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
class="w-full justify-start rounded-sm px-2 py-1.5 font-normal shadow-none"
|
||||||
|
/>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator class="md:hidden" />
|
<DropdownMenuSeparator class="md:hidden" />
|
||||||
<DropdownMenuItem @click="changePasswordDialogOpen = true">
|
<DropdownMenuItem @click="changePasswordDialogOpen = true">
|
||||||
@@ -2342,60 +2480,6 @@ onUnmounted(() => {
|
|||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile Status Row -->
|
|
||||||
<div class="md:hidden pb-2">
|
|
||||||
<div class="flex items-center gap-2 overflow-x-auto">
|
|
||||||
<div class="shrink-0">
|
|
||||||
<StatusCard
|
|
||||||
:title="t('statusCard.video')"
|
|
||||||
type="video"
|
|
||||||
:status="videoStatus"
|
|
||||||
:quick-info="videoQuickInfo"
|
|
||||||
:error-message="videoErrorMessage"
|
|
||||||
:details="videoDetails"
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="systemStore.audio?.available" class="shrink-0">
|
|
||||||
<StatusCard
|
|
||||||
:title="t('statusCard.audio')"
|
|
||||||
type="audio"
|
|
||||||
:status="audioStatus"
|
|
||||||
:quick-info="audioQuickInfo"
|
|
||||||
:error-message="audioErrorMessage"
|
|
||||||
:details="audioDetails"
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="shrink-0">
|
|
||||||
<StatusCard
|
|
||||||
:title="t('statusCard.hid')"
|
|
||||||
type="hid"
|
|
||||||
:status="hidStatus"
|
|
||||||
:quick-info="hidQuickInfo"
|
|
||||||
:details="hidDetails"
|
|
||||||
:hover-align="hidHoverAlign"
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="showMsdStatusCard" class="shrink-0">
|
|
||||||
<StatusCard
|
|
||||||
:title="t('statusCard.msd')"
|
|
||||||
type="msd"
|
|
||||||
:status="msdStatus"
|
|
||||||
:quick-info="msdQuickInfo"
|
|
||||||
:error-message="msdErrorMessage"
|
|
||||||
:details="msdDetails"
|
|
||||||
hover-align="end"
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -2428,7 +2512,7 @@ onUnmounted(() => {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Video Container -->
|
<!-- Video Container -->
|
||||||
<div class="relative h-full w-full flex items-center justify-center p-2 sm:p-4">
|
<div class="relative h-full w-full flex items-center justify-center p-1 sm:p-4">
|
||||||
<div
|
<div
|
||||||
ref="videoContainerRef"
|
ref="videoContainerRef"
|
||||||
class="relative bg-black overflow-hidden flex items-center justify-center"
|
class="relative bg-black overflow-hidden flex items-center justify-center"
|
||||||
@@ -2436,8 +2520,7 @@ onUnmounted(() => {
|
|||||||
aspectRatio: videoAspectRatio ?? '16/9',
|
aspectRatio: videoAspectRatio ?? '16/9',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
maxHeight: '100%',
|
maxHeight: '100%',
|
||||||
minWidth: '320px',
|
minHeight: '120px',
|
||||||
minHeight: '180px',
|
|
||||||
}"
|
}"
|
||||||
:class="{
|
:class="{
|
||||||
'opacity-60': videoLoading || videoError,
|
'opacity-60': videoLoading || videoError,
|
||||||
@@ -2491,11 +2574,11 @@ onUnmounted(() => {
|
|||||||
<div class="absolute w-full h-0.5 bg-gradient-to-r from-transparent via-primary/40 to-transparent animate-pulse" style="top: 50%; animation-duration: 1.5s;" />
|
<div class="absolute w-full h-0.5 bg-gradient-to-r from-transparent via-primary/40 to-transparent animate-pulse" style="top: 50%; animation-duration: 1.5s;" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Spinner class="h-16 w-16 text-white mb-4" />
|
<Spinner class="h-10 w-10 sm:h-16 sm:w-16 text-white mb-2 sm:mb-4" />
|
||||||
<p class="text-white/90 text-lg font-medium">
|
<p class="text-white/90 text-sm sm:text-lg font-medium text-center px-4">
|
||||||
{{ webrtcLoadingMessage }}
|
{{ webrtcLoadingMessage }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-white/50 text-sm mt-2">
|
<p class="text-white/50 text-xs sm:text-sm mt-1 sm:mt-2">
|
||||||
{{ t('console.pleaseWait') }}
|
{{ t('console.pleaseWait') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -2507,10 +2590,10 @@ onUnmounted(() => {
|
|||||||
v-if="videoError && !videoLoading"
|
v-if="videoError && !videoLoading"
|
||||||
class="absolute inset-0 flex flex-col items-center justify-center bg-black/85 text-white gap-4 transition-opacity duration-300 p-4"
|
class="absolute inset-0 flex flex-col items-center justify-center bg-black/85 text-white gap-4 transition-opacity duration-300 p-4"
|
||||||
>
|
>
|
||||||
<MonitorOff class="h-16 w-16 text-slate-400" />
|
<MonitorOff class="h-10 w-10 sm:h-16 sm:w-16 text-slate-400" />
|
||||||
<div class="text-center max-w-md">
|
<div class="text-center max-w-md px-2">
|
||||||
<p class="font-medium text-lg mb-2">{{ t('console.connectionFailed') }}</p>
|
<p class="font-medium text-sm sm:text-lg mb-1 sm:mb-2">{{ t('console.connectionFailed') }}</p>
|
||||||
<p class="text-sm text-slate-300 mb-3">{{ t('console.connectionFailedDesc') }}</p>
|
<p class="text-xs sm:text-sm text-slate-300 mb-2 sm:mb-3">{{ t('console.connectionFailedDesc') }}</p>
|
||||||
<!-- Expandable error details -->
|
<!-- Expandable error details -->
|
||||||
<div v-if="videoErrorMessage" class="bg-slate-800/60 rounded-lg p-3 text-left">
|
<div v-if="videoErrorMessage" class="bg-slate-800/60 rounded-lg p-3 text-left">
|
||||||
<p class="text-xs text-slate-400 mb-1">{{ t('console.errorDetails') }}:</p>
|
<p class="text-xs text-slate-400 mb-1">{{ t('console.errorDetails') }}:</p>
|
||||||
@@ -2568,22 +2651,22 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<!-- Terminal Dialog -->
|
<!-- Terminal Dialog -->
|
||||||
<Dialog v-model:open="showTerminalDialog">
|
<Dialog v-model:open="showTerminalDialog">
|
||||||
<DialogContent class="w-[95vw] max-w-5xl h-[85dvh] max-h-[720px] p-0 flex flex-col overflow-hidden">
|
<DialogContent class="w-[98vw] sm:w-[95vw] max-w-5xl h-[90dvh] sm:h-[85dvh] max-h-[720px] p-0 flex flex-col overflow-hidden">
|
||||||
<DialogHeader class="px-4 py-3 border-b shrink-0">
|
<DialogHeader class="px-3 sm:px-4 py-2 sm:py-3 border-b shrink-0">
|
||||||
<DialogTitle class="flex items-center justify-between w-full">
|
<DialogTitle class="flex items-center justify-between w-full">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Terminal class="h-5 w-5" />
|
<Terminal class="h-4 w-4 sm:h-5 sm:w-5" />
|
||||||
{{ t('extensions.ttyd.title') }}
|
<span class="text-sm sm:text-base">{{ t('extensions.ttyd.title') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-8 w-8 mr-8"
|
class="h-7 w-7 sm:h-8 sm:w-8 mr-6 sm:mr-8"
|
||||||
@click="openTerminalInNewTab"
|
@click="openTerminalInNewTab"
|
||||||
:aria-label="t('extensions.ttyd.openInNewTab')"
|
:aria-label="t('extensions.ttyd.openInNewTab')"
|
||||||
:title="t('extensions.ttyd.openInNewTab')"
|
:title="t('extensions.ttyd.openInNewTab')"
|
||||||
>
|
>
|
||||||
<ExternalLink class="h-4 w-4" />
|
<ExternalLink class="h-3.5 w-3.5 sm:h-4 sm:w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -2601,11 +2684,11 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<!-- Change Password Dialog -->
|
<!-- Change Password Dialog -->
|
||||||
<Dialog v-model:open="changePasswordDialogOpen">
|
<Dialog v-model:open="changePasswordDialogOpen">
|
||||||
<DialogContent class="sm:max-w-md">
|
<DialogContent class="w-[95vw] max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{{ t('auth.changePassword') }}</DialogTitle>
|
<DialogTitle>{{ t('auth.changePassword') }}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div class="space-y-4 py-4">
|
<div class="space-y-3 sm:space-y-4 py-2 sm:py-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="currentPassword">{{ t('auth.currentPassword') }}</Label>
|
<Label for="currentPassword">{{ t('auth.currentPassword') }}</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -3,34 +3,34 @@ import { ref } from 'vue'
|
|||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import {
|
|
||||||
setLanguage,
|
|
||||||
getCurrentLanguage,
|
|
||||||
type SupportedLocale,
|
|
||||||
} from '@/i18n'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Monitor, Lock, Eye, EyeOff, User } from 'lucide-vue-next'
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
|
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
|
||||||
|
import BrandMark from '@/components/BrandMark.vue'
|
||||||
|
import { Lock, Eye, EyeOff, User, CircleHelp } from 'lucide-vue-next'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const currentLanguage = ref<SupportedLocale>(getCurrentLanguage())
|
/** Map backend English messages to locale strings (API returns fixed English copy). */
|
||||||
|
function localizedLoginError(raw: string | null): string {
|
||||||
|
if (!raw) return t('auth.loginFailed')
|
||||||
|
if (raw.includes('Invalid username or password')) return t('auth.invalidPassword')
|
||||||
|
if (raw.includes('System not initialized')) return t('auth.systemNotInitialized')
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
const username = ref('')
|
const username = ref('')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
const showPassword = ref(false)
|
const showPassword = ref(false)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
|
|
||||||
function handleLanguageChange(lang: SupportedLocale) {
|
|
||||||
currentLanguage.value = lang
|
|
||||||
setLanguage(lang)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleLogin() {
|
async function handleLogin() {
|
||||||
if (!username.value) {
|
if (!username.value) {
|
||||||
error.value = t('auth.enterUsername')
|
error.value = t('auth.enterUsername')
|
||||||
@@ -50,7 +50,7 @@ async function handleLogin() {
|
|||||||
const redirect = route.query.redirect as string
|
const redirect = route.query.redirect as string
|
||||||
router.push(redirect || '/')
|
router.push(redirect || '/')
|
||||||
} else {
|
} else {
|
||||||
error.value = authStore.error || t('auth.loginFailed')
|
error.value = localizedLoginError(authStore.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -60,26 +60,13 @@ async function handleLogin() {
|
|||||||
<template>
|
<template>
|
||||||
<div class="min-h-screen min-h-dvh flex items-center justify-center bg-background p-4">
|
<div class="min-h-screen min-h-dvh flex items-center justify-center bg-background p-4">
|
||||||
<Card class="relative w-full max-w-sm">
|
<Card class="relative w-full max-w-sm">
|
||||||
<div class="absolute top-4 right-4 flex gap-2">
|
<div class="absolute top-4 right-4">
|
||||||
<Button
|
<LanguageToggleButton />
|
||||||
:variant="currentLanguage === 'zh-CN' ? 'default' : 'outline'"
|
|
||||||
size="sm"
|
|
||||||
@click="handleLanguageChange('zh-CN')"
|
|
||||||
>
|
|
||||||
中文
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
:variant="currentLanguage === 'en-US' ? 'default' : 'outline'"
|
|
||||||
size="sm"
|
|
||||||
@click="handleLanguageChange('en-US')"
|
|
||||||
>
|
|
||||||
English
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardHeader class="space-y-2 pt-10 text-center sm:pt-12">
|
<CardHeader class="space-y-2 pt-10 text-center sm:pt-12">
|
||||||
<div class="inline-flex h-16 w-16 items-center justify-center rounded-full bg-primary/10 mx-auto">
|
<div class="mx-auto flex justify-center">
|
||||||
<Monitor class="w-8 h-8 text-primary" />
|
<BrandMark size="xl" />
|
||||||
</div>
|
</div>
|
||||||
<CardTitle class="text-xl sm:text-2xl">One-KVM</CardTitle>
|
<CardTitle class="text-xl sm:text-2xl">One-KVM</CardTitle>
|
||||||
<CardDescription>{{ t('auth.login') }}</CardDescription>
|
<CardDescription>{{ t('auth.login') }}</CardDescription>
|
||||||
@@ -130,6 +117,25 @@ async function handleLogin() {
|
|||||||
<span v-if="loading">{{ t('common.loading') }}</span>
|
<span v-if="loading">{{ t('common.loading') }}</span>
|
||||||
<span v-else>{{ t('auth.login') }}</span>
|
<span v-else>{{ t('auth.login') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<div class="text-right">
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger as-child>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{{ t('auth.forgotPassword') }}
|
||||||
|
<CircleHelp class="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent class="w-80 p-3" align="end">
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
{{ t('auth.forgotPasswordHint') }}
|
||||||
|
</p>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -37,10 +37,11 @@ import type {
|
|||||||
OtgHidProfile,
|
OtgHidProfile,
|
||||||
OtgHidFunctions,
|
OtgHidFunctions,
|
||||||
} from '@/types/generated'
|
} from '@/types/generated'
|
||||||
import { setLanguage } from '@/i18n'
|
import { formatFpsLabel, toConfigFps } from '@/lib/fps'
|
||||||
import { useClipboard } from '@/composables/useClipboard'
|
import { useClipboard } from '@/composables/useClipboard'
|
||||||
import { getVideoFormatState } from '@/lib/video-format-support'
|
import { getVideoFormatState } from '@/lib/video-format-support'
|
||||||
import AppLayout from '@/components/AppLayout.vue'
|
import AppLayout from '@/components/AppLayout.vue'
|
||||||
|
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
@@ -49,6 +50,8 @@ import { Switch } from '@/components/ui/switch'
|
|||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'
|
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'
|
||||||
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -83,9 +86,10 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
ScreenShare,
|
ScreenShare,
|
||||||
Radio,
|
Radio,
|
||||||
|
Globe,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
const { t, te, locale } = useI18n()
|
const { t, te } = useI18n()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const systemStore = useSystemStore()
|
const systemStore = useSystemStore()
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
@@ -99,7 +103,7 @@ const saved = ref(false)
|
|||||||
const SETTINGS_SECTION_IDS = new Set([
|
const SETTINGS_SECTION_IDS = new Set([
|
||||||
'appearance',
|
'appearance',
|
||||||
'account',
|
'account',
|
||||||
'access',
|
'network',
|
||||||
'video',
|
'video',
|
||||||
'hid',
|
'hid',
|
||||||
'msd',
|
'msd',
|
||||||
@@ -119,7 +123,7 @@ const navGroups = computed(() => [
|
|||||||
items: [
|
items: [
|
||||||
{ id: 'appearance', label: t('settings.appearance'), icon: Sun },
|
{ id: 'appearance', label: t('settings.appearance'), icon: Sun },
|
||||||
{ id: 'account', label: t('settings.account'), icon: User },
|
{ id: 'account', label: t('settings.account'), icon: User },
|
||||||
{ id: 'access', label: t('settings.access'), icon: Lock },
|
{ id: 'network', label: t('settings.network'), icon: Globe },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -155,7 +159,9 @@ function selectSection(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSettingsSection(value: unknown): string | null {
|
function normalizeSettingsSection(value: unknown): string | null {
|
||||||
return typeof value === 'string' && SETTINGS_SECTION_IDS.has(value) ? value : null
|
if (typeof value !== 'string') return null
|
||||||
|
if (value === 'access-control') return 'account'
|
||||||
|
return SETTINGS_SECTION_IDS.has(value) ? value : null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Theme
|
// Theme
|
||||||
@@ -204,7 +210,7 @@ const showTerminalDialog = ref(false)
|
|||||||
// Extension config (local edit state)
|
// Extension config (local edit state)
|
||||||
const extConfig = ref({
|
const extConfig = ref({
|
||||||
ttyd: { enabled: false, shell: '/bin/bash' },
|
ttyd: { enabled: false, shell: '/bin/bash' },
|
||||||
gostc: { enabled: false, addr: 'gostc.mofeng.run', key: '', tls: true },
|
gostc: { enabled: false, addr: '', key: '', tls: true },
|
||||||
easytier: { enabled: false, network_name: '', network_secret: '', peer_urls: [] as string[], virtual_ip: '' },
|
easytier: { enabled: false, network_name: '', network_secret: '', peer_urls: [] as string[], virtual_ip: '' },
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -235,8 +241,16 @@ const rtspLocalConfig = ref<RtspConfigUpdate & { password?: string }>({
|
|||||||
username: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function formatHostForUrl(hostname: string): string {
|
||||||
|
if (!hostname) return '127.0.0.1'
|
||||||
|
return hostname.includes(':') && !hostname.startsWith('[')
|
||||||
|
? `[${hostname}]`
|
||||||
|
: hostname
|
||||||
|
}
|
||||||
|
|
||||||
const rtspStreamUrl = computed(() => {
|
const rtspStreamUrl = computed(() => {
|
||||||
const host = window.location.hostname || '127.0.0.1'
|
const host = formatHostForUrl(window.location.hostname || '127.0.0.1')
|
||||||
const path = (rtspLocalConfig.value.path || 'live').trim().replace(/^\/+|\/+$/g, '') || 'live'
|
const path = (rtspLocalConfig.value.path || 'live').trim().replace(/^\/+|\/+$/g, '') || 'live'
|
||||||
const port = Number(rtspLocalConfig.value.port) || 8554
|
const port = Number(rtspLocalConfig.value.port) || 8554
|
||||||
return `rtsp://${host}:${port}/${path}`
|
return `rtsp://${host}:${port}/${path}`
|
||||||
@@ -249,10 +263,22 @@ const webServerConfig = ref<WebConfig>({
|
|||||||
bind_address: '0.0.0.0',
|
bind_address: '0.0.0.0',
|
||||||
bind_addresses: ['0.0.0.0'],
|
bind_addresses: ['0.0.0.0'],
|
||||||
https_enabled: false,
|
https_enabled: false,
|
||||||
|
has_custom_cert: false,
|
||||||
})
|
})
|
||||||
const webServerLoading = ref(false)
|
const webServerLoading = ref(false)
|
||||||
|
// SSL certificate state
|
||||||
|
const sslCertPem = ref('')
|
||||||
|
const sslKeyPem = ref('')
|
||||||
|
const certSaving = ref(false)
|
||||||
|
const certClearing = ref(false)
|
||||||
const showRestartDialog = ref(false)
|
const showRestartDialog = ref(false)
|
||||||
const restarting = ref(false)
|
const restarting = ref(false)
|
||||||
|
// Auto-restart flow (no dialog needed for web-config saves)
|
||||||
|
const autoRestarting = ref(false)
|
||||||
|
const autoRestartFailed = ref(false)
|
||||||
|
// For HTTPS targets: can't poll (self-signed cert), show manual link instead
|
||||||
|
const autoRestartManualUrl = ref<string | null>(null)
|
||||||
|
const autoRestartCountdown = ref(0)
|
||||||
const updateChannel = ref<UpdateChannel>('stable')
|
const updateChannel = ref<UpdateChannel>('stable')
|
||||||
const updateOverview = ref<UpdateOverviewResponse | null>(null)
|
const updateOverview = ref<UpdateOverviewResponse | null>(null)
|
||||||
const updateStatus = ref<UpdateStatusResponse | null>(null)
|
const updateStatus = ref<UpdateStatusResponse | null>(null)
|
||||||
@@ -290,6 +316,18 @@ const effectiveBindAddresses = computed(() => {
|
|||||||
return normalizeBindAddresses(bindAddressList.value)
|
return normalizeBindAddresses(bindAddressList.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 预览当前配置生效后的访问 URL(取第一个非通配地址显示) */
|
||||||
|
const previewAccessUrl = computed(() => {
|
||||||
|
const https = webServerConfig.value.https_enabled
|
||||||
|
const port = https ? webServerConfig.value.https_port : webServerConfig.value.http_port
|
||||||
|
const scheme = https ? 'https' : 'http'
|
||||||
|
// 对通配地址,用当前浏览器 hostname 替代
|
||||||
|
const addrs = effectiveBindAddresses.value
|
||||||
|
const firstAddr = addrs.find(a => a !== '0.0.0.0' && a !== '::') ?? window.location.hostname
|
||||||
|
const host = firstAddr.includes(':') ? `[${firstAddr}]` : firstAddr
|
||||||
|
return `${scheme}://${host}:${port}`
|
||||||
|
})
|
||||||
|
|
||||||
// Config
|
// Config
|
||||||
interface DeviceConfig {
|
interface DeviceConfig {
|
||||||
video: Array<{
|
video: Array<{
|
||||||
@@ -902,13 +940,6 @@ function setTheme(newTheme: 'light' | 'dark' | 'system') {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Language handling
|
|
||||||
function handleLanguageChange(lang: string) {
|
|
||||||
if (lang === 'zh-CN' || lang === 'en-US') {
|
|
||||||
setLanguage(lang)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Account updates
|
// Account updates
|
||||||
async function changeUsername() {
|
async function changeUsername() {
|
||||||
usernameError.value = ''
|
usernameError.value = ''
|
||||||
@@ -980,33 +1011,29 @@ async function saveConfig() {
|
|||||||
saved.value = false
|
saved.value = false
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Save only config related to the active section
|
// Save only config related to the active section.
|
||||||
const savePromises: Promise<unknown>[] = []
|
// Sequential awaits: backend ConfigStore uses read-modify-write; parallel PATCH
|
||||||
|
// requests could overwrite each other's section (last writer wins on full JSON).
|
||||||
|
|
||||||
// Video config (including encoder and WebRTC/STUN/TURN settings)
|
// Video config (including encoder and WebRTC/STUN/TURN settings)
|
||||||
if (activeSection.value === 'video') {
|
if (activeSection.value === 'video') {
|
||||||
savePromises.push(
|
await configStore.updateVideo({
|
||||||
configStore.updateVideo({
|
|
||||||
device: config.value.video_device || undefined,
|
device: config.value.video_device || undefined,
|
||||||
format: config.value.video_format || undefined,
|
format: config.value.video_format || undefined,
|
||||||
width: config.value.video_width,
|
width: config.value.video_width,
|
||||||
height: config.value.video_height,
|
height: config.value.video_height,
|
||||||
fps: config.value.video_fps,
|
fps: toConfigFps(config.value.video_fps),
|
||||||
})
|
})
|
||||||
)
|
await configStore.updateStream({
|
||||||
// Save Stream/Encoder and STUN/TURN config together
|
|
||||||
savePromises.push(
|
|
||||||
configStore.updateStream({
|
|
||||||
encoder: config.value.encoder_backend as any,
|
encoder: config.value.encoder_backend as any,
|
||||||
stun_server: config.value.stun_server || undefined,
|
stun_server: config.value.stun_server || undefined,
|
||||||
turn_server: config.value.turn_server || undefined,
|
turn_server: config.value.turn_server || undefined,
|
||||||
turn_username: config.value.turn_username || undefined,
|
turn_username: config.value.turn_username || undefined,
|
||||||
turn_password: config.value.turn_password || undefined,
|
turn_password: config.value.turn_password || undefined,
|
||||||
})
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// HID config
|
// HID config (includes MSD enable — same gadget; must not race with updateHid)
|
||||||
if (activeSection.value === 'hid') {
|
if (activeSection.value === 'hid') {
|
||||||
if (!isHidFunctionSelectionValid.value || !isOtgEndpointBudgetValid.value) {
|
if (!isHidFunctionSelectionValid.value || !isOtgEndpointBudgetValid.value) {
|
||||||
return
|
return
|
||||||
@@ -1030,24 +1057,20 @@ async function saveConfig() {
|
|||||||
hidUpdate.otg_functions = { ...config.value.hid_otg_functions }
|
hidUpdate.otg_functions = { ...config.value.hid_otg_functions }
|
||||||
hidUpdate.otg_keyboard_leds = config.value.hid_otg_keyboard_leds
|
hidUpdate.otg_keyboard_leds = config.value.hid_otg_keyboard_leds
|
||||||
}
|
}
|
||||||
savePromises.push(configStore.updateHid(hidUpdate))
|
await configStore.updateHid(hidUpdate)
|
||||||
savePromises.push(
|
await configStore.updateMsd({
|
||||||
configStore.updateMsd({
|
|
||||||
enabled: config.value.msd_enabled,
|
enabled: config.value.msd_enabled,
|
||||||
})
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MSD config
|
// MSD config
|
||||||
if (activeSection.value === 'msd') {
|
if (activeSection.value === 'msd') {
|
||||||
savePromises.push(
|
await configStore.updateMsd({
|
||||||
configStore.updateMsd({
|
|
||||||
msd_dir: config.value.msd_dir || undefined,
|
msd_dir: config.value.msd_dir || undefined,
|
||||||
})
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(savePromises)
|
await loadConfig()
|
||||||
saved.value = true
|
saved.value = true
|
||||||
setTimeout(() => (saved.value = false), 2000)
|
setTimeout(() => (saved.value = false), 2000)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1441,6 +1464,12 @@ function normalizeRustdeskServer(value: string, defaultPort: number): string | u
|
|||||||
return `${trimmed}:${defaultPort}`
|
return `${trimmed}:${defaultPort}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Strip line breaks from pasted keys; empty means “do not change” on PATCH. */
|
||||||
|
function normalizeRustdeskRelayKey(value: string): string | undefined {
|
||||||
|
const cleaned = value.replace(/\r?\n/g, '').trim()
|
||||||
|
return cleaned || undefined
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeRtspPath(path: string): string {
|
function normalizeRtspPath(path: string): string {
|
||||||
return path.trim().replace(/^\/+|\/+$/g, '') || 'live'
|
return path.trim().replace(/^\/+|\/+$/g, '') || 'live'
|
||||||
}
|
}
|
||||||
@@ -1502,16 +1531,15 @@ async function saveWebServerConfig() {
|
|||||||
if (bindAddressError.value) return
|
if (bindAddressError.value) return
|
||||||
webServerLoading.value = true
|
webServerLoading.value = true
|
||||||
try {
|
try {
|
||||||
const update = {
|
const updated = await configStore.updateWeb({
|
||||||
http_port: webServerConfig.value.http_port,
|
http_port: webServerConfig.value.http_port,
|
||||||
https_port: webServerConfig.value.https_port,
|
https_port: webServerConfig.value.https_port,
|
||||||
https_enabled: webServerConfig.value.https_enabled,
|
https_enabled: webServerConfig.value.https_enabled,
|
||||||
bind_addresses: effectiveBindAddresses.value,
|
bind_addresses: effectiveBindAddresses.value,
|
||||||
}
|
})
|
||||||
const updated = await configStore.updateWeb(update)
|
|
||||||
webServerConfig.value = updated
|
webServerConfig.value = updated
|
||||||
applyBindStateFromConfig(updated)
|
applyBindStateFromConfig(updated)
|
||||||
showRestartDialog.value = true
|
await triggerAutoRestart()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to save web server config:', e)
|
console.error('Failed to save web server config:', e)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1519,18 +1547,50 @@ async function saveWebServerConfig() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveCertificate() {
|
||||||
|
if (!sslCertPem.value.trim() || !sslKeyPem.value.trim()) return
|
||||||
|
certSaving.value = true
|
||||||
|
try {
|
||||||
|
const updated = await configStore.updateWeb({
|
||||||
|
ssl_cert_pem: sslCertPem.value,
|
||||||
|
ssl_key_pem: sslKeyPem.value,
|
||||||
|
})
|
||||||
|
webServerConfig.value = updated
|
||||||
|
sslCertPem.value = ''
|
||||||
|
sslKeyPem.value = ''
|
||||||
|
await triggerAutoRestart()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save certificate:', e)
|
||||||
|
} finally {
|
||||||
|
certSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearCertificate() {
|
||||||
|
certClearing.value = true
|
||||||
|
try {
|
||||||
|
const updated = await configStore.updateWeb({ clear_custom_cert: true })
|
||||||
|
webServerConfig.value = updated
|
||||||
|
await triggerAutoRestart()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to clear certificate:', e)
|
||||||
|
} finally {
|
||||||
|
certClearing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 手动点重启按钮(仅用于弹窗场景,保留兼容) */
|
||||||
async function restartServer() {
|
async function restartServer() {
|
||||||
restarting.value = true
|
restarting.value = true
|
||||||
try {
|
try {
|
||||||
await systemApi.restart()
|
await systemApi.restart()
|
||||||
// Wait for server to restart, then reload page
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const protocol = webServerConfig.value.https_enabled ? 'https' : 'http'
|
const protocol = webServerConfig.value.https_enabled ? 'https' : 'http'
|
||||||
const port = webServerConfig.value.https_enabled
|
const port = webServerConfig.value.https_enabled
|
||||||
? webServerConfig.value.https_port
|
? webServerConfig.value.https_port
|
||||||
: webServerConfig.value.http_port
|
: webServerConfig.value.http_port
|
||||||
const newUrl = `${protocol}://${window.location.hostname}:${port}`
|
const host = formatHostForUrl(window.location.hostname || '127.0.0.1')
|
||||||
window.location.href = newUrl
|
window.location.href = `${protocol}://${host}:${port}`
|
||||||
}, 3000)
|
}, 3000)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to restart server:', e)
|
console.error('Failed to restart server:', e)
|
||||||
@@ -1538,6 +1598,75 @@ async function restartServer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 轮询目标地址 /api/health,最多等待 maxMs 毫秒 */
|
||||||
|
async function pollUntilReady(targetOrigin: string, maxMs = 30000): Promise<boolean> {
|
||||||
|
const deadline = Date.now() + maxMs
|
||||||
|
const healthUrl = targetOrigin.replace(/\/$/, '') + '/api/health'
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
await new Promise(r => setTimeout(r, 800))
|
||||||
|
try {
|
||||||
|
const ctrl = new AbortController()
|
||||||
|
const tid = setTimeout(() => ctrl.abort(), 1500)
|
||||||
|
const res = await fetch(healthUrl, { signal: ctrl.signal })
|
||||||
|
clearTimeout(tid)
|
||||||
|
if (res.ok) return true
|
||||||
|
} catch {
|
||||||
|
// server still restarting — keep polling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存网络配置后自动重启并跳转。
|
||||||
|
*
|
||||||
|
* - HTTP 目标:轮询 /api/health,服务恢复后自动跳转。
|
||||||
|
* - HTTPS 目标:自签名证书导致 fetch 被浏览器拦截(ERR_CERT_AUTHORITY_INVALID),
|
||||||
|
* 无法自动轮询。改为倒计时结束后展示跳转链接,由用户点击并在浏览器中手动接受证书。
|
||||||
|
*/
|
||||||
|
async function triggerAutoRestart() {
|
||||||
|
const https = webServerConfig.value.https_enabled
|
||||||
|
const port = https ? webServerConfig.value.https_port : webServerConfig.value.http_port
|
||||||
|
const protocol = https ? 'https' : 'http'
|
||||||
|
const host = formatHostForUrl(window.location.hostname || '127.0.0.1')
|
||||||
|
const targetOrigin = `${protocol}://${host}:${port}`
|
||||||
|
|
||||||
|
autoRestarting.value = true
|
||||||
|
autoRestartFailed.value = false
|
||||||
|
autoRestartManualUrl.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
await systemApi.restart()
|
||||||
|
|
||||||
|
if (https) {
|
||||||
|
// HTTPS:浏览器拒绝自签名证书,无法轮询。
|
||||||
|
// 等待固定时间后展示手动跳转链接。
|
||||||
|
const WAIT_SEC = 6
|
||||||
|
autoRestartCountdown.value = WAIT_SEC
|
||||||
|
for (let i = WAIT_SEC - 1; i >= 0; i--) {
|
||||||
|
await new Promise(r => setTimeout(r, 1000))
|
||||||
|
autoRestartCountdown.value = i
|
||||||
|
}
|
||||||
|
autoRestartManualUrl.value = targetOrigin
|
||||||
|
autoRestarting.value = false
|
||||||
|
} else {
|
||||||
|
// HTTP:可以安全轮询,服务恢复后自动跳转。
|
||||||
|
await new Promise(r => setTimeout(r, 1200))
|
||||||
|
const ready = await pollUntilReady(targetOrigin)
|
||||||
|
if (ready) {
|
||||||
|
window.location.href = targetOrigin
|
||||||
|
} else {
|
||||||
|
autoRestartFailed.value = true
|
||||||
|
autoRestarting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Auto restart failed:', e)
|
||||||
|
autoRestartFailed.value = true
|
||||||
|
autoRestarting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadUpdateOverview() {
|
async function loadUpdateOverview() {
|
||||||
updateLoading.value = true
|
updateLoading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -1647,7 +1776,7 @@ async function saveRustdeskConfig() {
|
|||||||
enabled: rustdeskLocalConfig.value.enabled,
|
enabled: rustdeskLocalConfig.value.enabled,
|
||||||
rendezvous_server: rendezvousServer,
|
rendezvous_server: rendezvousServer,
|
||||||
relay_server: relayServer,
|
relay_server: relayServer,
|
||||||
relay_key: rustdeskLocalConfig.value.relay_key || undefined,
|
relay_key: normalizeRustdeskRelayKey(rustdeskLocalConfig.value.relay_key),
|
||||||
})
|
})
|
||||||
await loadRustdeskConfig()
|
await loadRustdeskConfig()
|
||||||
// Clear relay_key input after save (it's a password field)
|
// Clear relay_key input after save (it's a password field)
|
||||||
@@ -1924,16 +2053,16 @@ watch(() => route.query.tab, (tab) => {
|
|||||||
<AppLayout>
|
<AppLayout>
|
||||||
<div class="flex h-full overflow-hidden">
|
<div class="flex h-full overflow-hidden">
|
||||||
<!-- Mobile Header -->
|
<!-- Mobile Header -->
|
||||||
<div class="lg:hidden fixed top-16 left-0 right-0 z-20 flex items-center px-4 py-3 border-b bg-background">
|
<div class="lg:hidden fixed top-11 sm:top-14 left-0 right-0 z-20 flex items-center px-3 sm:px-4 py-2 sm:py-3 border-b bg-background">
|
||||||
<Sheet v-model:open="mobileMenuOpen">
|
<Sheet v-model:open="mobileMenuOpen">
|
||||||
<SheetTrigger as-child>
|
<SheetTrigger as-child>
|
||||||
<Button variant="ghost" size="icon" class="mr-2 h-9 w-9">
|
<Button variant="ghost" size="icon" class="mr-1.5 sm:mr-2 h-8 w-8 sm:h-9 sm:w-9">
|
||||||
<Menu class="h-4 w-4" />
|
<Menu class="h-4 w-4" />
|
||||||
<span class="sr-only">{{ t('common.menu') }}</span>
|
<span class="sr-only">{{ t('common.menu') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent side="left" class="w-72 p-0">
|
<SheetContent side="left" class="w-72 p-0">
|
||||||
<div class="p-6">
|
<div class="p-4 sm:p-6">
|
||||||
<h2 class="text-lg font-semibold mb-4">{{ t('settings.title') }}</h2>
|
<h2 class="text-lg font-semibold mb-4">{{ t('settings.title') }}</h2>
|
||||||
<nav class="space-y-6">
|
<nav class="space-y-6">
|
||||||
<div v-for="group in navGroups" :key="group.title" class="space-y-1">
|
<div v-for="group in navGroups" :key="group.title" class="space-y-1">
|
||||||
@@ -1959,7 +2088,7 @@ watch(() => route.query.tab, (tab) => {
|
|||||||
</div>
|
</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
<h1 class="text-lg font-semibold">{{ t('settings.title') }}</h1>
|
<h1 class="text-base sm:text-lg font-semibold">{{ t('settings.title') }}</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Desktop Sidebar -->
|
<!-- Desktop Sidebar -->
|
||||||
@@ -1992,7 +2121,7 @@ watch(() => route.query.tab, (tab) => {
|
|||||||
|
|
||||||
<!-- Main Content -->
|
<!-- Main Content -->
|
||||||
<main class="flex-1 overflow-y-auto">
|
<main class="flex-1 overflow-y-auto">
|
||||||
<div class="max-w-2xl mx-auto p-6 lg:p-8 pt-20 lg:pt-8 space-y-6">
|
<div class="max-w-2xl mx-auto p-3 sm:p-6 lg:p-8 pt-16 sm:pt-20 lg:pt-8 space-y-4 sm:space-y-6">
|
||||||
|
|
||||||
<!-- Appearance Section -->
|
<!-- Appearance Section -->
|
||||||
<div v-show="activeSection === 'appearance'" class="space-y-6">
|
<div v-show="activeSection === 'appearance'" class="space-y-6">
|
||||||
@@ -2002,15 +2131,15 @@ watch(() => route.query.tab, (tab) => {
|
|||||||
<CardDescription>{{ t('settings.themeDesc') }}</CardDescription>
|
<CardDescription>{{ t('settings.themeDesc') }}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div class="flex gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<Button :variant="theme === 'light' ? 'default' : 'outline'" size="sm" @click="setTheme('light')">
|
<Button :variant="theme === 'light' ? 'default' : 'outline'" size="sm" @click="setTheme('light')">
|
||||||
<Sun class="h-4 w-4 mr-2" />{{ t('settings.lightMode') }}
|
<Sun class="h-4 w-4 mr-1.5" />{{ t('settings.lightMode') }}
|
||||||
</Button>
|
</Button>
|
||||||
<Button :variant="theme === 'dark' ? 'default' : 'outline'" size="sm" @click="setTheme('dark')">
|
<Button :variant="theme === 'dark' ? 'default' : 'outline'" size="sm" @click="setTheme('dark')">
|
||||||
<Moon class="h-4 w-4 mr-2" />{{ t('settings.darkMode') }}
|
<Moon class="h-4 w-4 mr-1.5" />{{ t('settings.darkMode') }}
|
||||||
</Button>
|
</Button>
|
||||||
<Button :variant="theme === 'system' ? 'default' : 'outline'" size="sm" @click="setTheme('system')">
|
<Button :variant="theme === 'system' ? 'default' : 'outline'" size="sm" @click="setTheme('system')">
|
||||||
<Monitor class="h-4 w-4 mr-2" />{{ t('settings.systemMode') }}
|
<Monitor class="h-4 w-4 mr-1.5" />{{ t('settings.systemMode') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -2022,9 +2151,8 @@ watch(() => route.query.tab, (tab) => {
|
|||||||
<CardDescription>{{ t('settings.languageDesc') }}</CardDescription>
|
<CardDescription>{{ t('settings.languageDesc') }}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div class="flex gap-2">
|
<div class="flex">
|
||||||
<Button :variant="locale === 'zh-CN' ? 'default' : 'outline'" size="sm" @click="handleLanguageChange('zh-CN')">中文</Button>
|
<LanguageToggleButton variant="outline" size="sm" label-mode="current" />
|
||||||
<Button :variant="locale === 'en-US' ? 'default' : 'outline'" size="sm" @click="handleLanguageChange('en-US')">English</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -2085,6 +2213,31 @@ watch(() => route.query.tab, (tab) => {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{{ t('settings.authSettings') }}</CardTitle>
|
||||||
|
<CardDescription>{{ t('settings.authSettingsDesc') }}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="space-y-0.5">
|
||||||
|
<Label>{{ t('settings.allowMultipleSessions') }}</Label>
|
||||||
|
<p class="text-xs text-muted-foreground">{{ t('settings.allowMultipleSessionsDesc') }}</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
v-model="authConfig.single_user_allow_multiple_sessions"
|
||||||
|
:disabled="authConfigLoading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end pt-2">
|
||||||
|
<Button @click="saveAuthConfig" :disabled="authConfigLoading">
|
||||||
|
<Save class="h-4 w-4 mr-2" />
|
||||||
|
{{ t('common.save') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Video Section -->
|
<!-- Video Section -->
|
||||||
@@ -2132,8 +2285,8 @@ watch(() => route.query.tab, (tab) => {
|
|||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<Label for="video-fps">{{ t('settings.frameRate') }}</Label>
|
<Label for="video-fps">{{ t('settings.frameRate') }}</Label>
|
||||||
<select id="video-fps" v-model.number="config.video_fps" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm" :disabled="!config.video_format">
|
<select id="video-fps" v-model.number="config.video_fps" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm" :disabled="!config.video_format">
|
||||||
<option v-for="fps in availableFps" :key="fps" :value="fps">{{ fps }} FPS</option>
|
<option v-for="fps in availableFps" :key="fps" :value="fps">{{ formatFpsLabel(fps) }}</option>
|
||||||
<option v-if="!availableFps.includes(config.video_fps)" :value="config.video_fps">{{ config.video_fps }} FPS</option>
|
<option v-if="!availableFps.includes(config.video_fps)" :value="config.video_fps">{{ formatFpsLabel(config.video_fps) }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2591,14 +2744,70 @@ watch(() => route.query.tab, (tab) => {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Access Section -->
|
<!-- Network Section -->
|
||||||
<div v-show="activeSection === 'access'" class="space-y-6">
|
<div v-show="activeSection === 'network'" class="space-y-6">
|
||||||
|
|
||||||
|
<!-- Auto-restart: restarting progress -->
|
||||||
|
<div
|
||||||
|
v-if="autoRestarting"
|
||||||
|
class="flex items-center gap-3 rounded-lg border bg-card px-4 py-3 text-sm shadow-sm"
|
||||||
|
>
|
||||||
|
<RefreshCw class="h-4 w-4 animate-spin text-primary shrink-0" />
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="font-medium">{{ t('settings.autoRestarting') }}</p>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
{{ webServerConfig.https_enabled
|
||||||
|
? t('settings.autoRestartingHttpsDesc', { sec: autoRestartCountdown })
|
||||||
|
: t('settings.autoRestartingDesc') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span v-if="webServerConfig.https_enabled && autoRestartCountdown > 0"
|
||||||
|
class="tabular-nums text-lg font-bold text-primary shrink-0">
|
||||||
|
{{ autoRestartCountdown }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auto-restart: HTTPS manual redirect (cert must be accepted by user) -->
|
||||||
|
<div
|
||||||
|
v-if="autoRestartManualUrl"
|
||||||
|
class="rounded-lg border border-amber-300 bg-amber-50 dark:bg-amber-950/20 dark:border-amber-700 px-4 py-4 space-y-3"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-2 text-sm text-amber-800 dark:text-amber-300">
|
||||||
|
<Lock class="h-4 w-4 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<p class="font-medium">{{ t('settings.httpsManualRedirectTitle') }}</p>
|
||||||
|
<p class="text-xs mt-0.5 opacity-80">{{ t('settings.httpsManualRedirectDesc') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
:href="autoRestartManualUrl"
|
||||||
|
class="flex items-center justify-center gap-2 w-full rounded-md bg-amber-600 hover:bg-amber-700 text-white text-sm font-medium px-4 py-2 transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink class="h-4 w-4" />
|
||||||
|
{{ autoRestartManualUrl }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auto-restart: failure / timeout -->
|
||||||
|
<div
|
||||||
|
v-if="autoRestartFailed"
|
||||||
|
class="flex items-center justify-between rounded-lg border border-destructive/40 bg-destructive/5 px-4 py-3 text-sm"
|
||||||
|
>
|
||||||
|
<p class="text-destructive">{{ t('settings.autoRestartFailed') }}</p>
|
||||||
|
<Button variant="outline" size="sm" @click="triggerAutoRestart">
|
||||||
|
<RefreshCw class="h-3 w-3 mr-1" />
|
||||||
|
{{ t('common.retry') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Port Configuration Card -->
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{{ t('settings.webServer') }}</CardTitle>
|
<CardTitle>{{ t('settings.portConfig') }}</CardTitle>
|
||||||
<CardDescription>{{ t('settings.webServerDesc') }}</CardDescription>
|
<CardDescription>{{ t('settings.portConfigDesc') }}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-4">
|
<CardContent class="space-y-4">
|
||||||
|
<!-- HTTPS toggle -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="space-y-0.5">
|
<div class="space-y-0.5">
|
||||||
<Label>{{ t('settings.httpsEnabled') }}</Label>
|
<Label>{{ t('settings.httpsEnabled') }}</Label>
|
||||||
@@ -2609,46 +2818,113 @@ watch(() => route.query.tab, (tab) => {
|
|||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
<!-- Single active-port input, label follows the HTTPS toggle -->
|
||||||
<div class="space-y-2">
|
<div class="flex items-end gap-3">
|
||||||
<Label>{{ t('settings.httpPort') }}</Label>
|
<div class="space-y-2 flex-1 max-w-[180px]">
|
||||||
<Input v-model.number="webServerConfig.http_port" type="number" min="1" max="65535" />
|
<Label>
|
||||||
|
{{ webServerConfig.https_enabled ? t('settings.httpsPort') : t('settings.httpPort') }}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
v-if="webServerConfig.https_enabled"
|
||||||
|
v-model.number="webServerConfig.https_port"
|
||||||
|
type="number" min="1" max="65535"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-else
|
||||||
|
v-model.number="webServerConfig.http_port"
|
||||||
|
type="number" min="1" max="65535"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<!-- Inactive-port reference (read-only hint) -->
|
||||||
<Label>{{ t('settings.httpsPort') }}</Label>
|
<div class="space-y-2 flex-1 max-w-[180px]">
|
||||||
<Input v-model.number="webServerConfig.https_port" type="number" min="1" max="65535" />
|
<Label class="text-muted-foreground text-xs">
|
||||||
|
{{ webServerConfig.https_enabled ? t('settings.httpPortReserved') : t('settings.httpsPortReserved') }}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
v-if="webServerConfig.https_enabled"
|
||||||
|
v-model.number="webServerConfig.http_port"
|
||||||
|
type="number" min="1" max="65535"
|
||||||
|
class="opacity-50"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-else
|
||||||
|
v-model.number="webServerConfig.https_port"
|
||||||
|
type="number" min="1" max="65535"
|
||||||
|
class="opacity-50"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-2">
|
<!-- Preview URL -->
|
||||||
<Label>{{ t('settings.bindMode') }}</Label>
|
<div class="flex items-center gap-2 rounded-md bg-muted px-3 py-2 text-sm">
|
||||||
<select v-model="bindMode" class="w-full h-9 px-3 rounded-md border border-input bg-background text-sm">
|
<span class="text-muted-foreground shrink-0">{{ t('settings.previewUrl') }}:</span>
|
||||||
<option value="all">{{ t('settings.bindModeAll') }}</option>
|
<span class="font-mono text-xs break-all">{{ previewAccessUrl }}</span>
|
||||||
<option value="loopback">{{ t('settings.bindModeLocal') }}</option>
|
|
||||||
<option value="custom">{{ t('settings.bindModeCustom') }}</option>
|
|
||||||
</select>
|
|
||||||
<p class="text-sm text-muted-foreground">{{ t('settings.bindModeDesc') }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="bindMode === 'all'" class="flex items-center justify-between">
|
<!-- Save row -->
|
||||||
<div class="space-y-0.5">
|
<div class="flex items-center justify-between pt-2">
|
||||||
<Label>{{ t('settings.bindIpv6') }}</Label>
|
<p class="text-xs text-muted-foreground">⚠ {{ t('settings.restartRequired') }}</p>
|
||||||
<p class="text-xs text-muted-foreground">{{ t('settings.bindAllDesc') }}</p>
|
<Button @click="saveWebServerConfig" :disabled="webServerLoading || autoRestarting">
|
||||||
|
<RefreshCw v-if="autoRestarting" class="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
<Save v-else class="h-4 w-4 mr-2" />
|
||||||
|
{{ autoRestarting ? t('settings.restarting') : t('common.save') }}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- Listen Address Card -->
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{{ t('settings.listenAddress') }}</CardTitle>
|
||||||
|
<CardDescription>{{ t('settings.listenAddressDesc') }}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4">
|
||||||
|
<RadioGroup v-model="bindMode" class="space-y-3">
|
||||||
|
<!-- All addresses -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<RadioGroupItem value="all" id="bind-all" class="mt-0.5" />
|
||||||
|
<div class="flex-1">
|
||||||
|
<Label for="bind-all" class="cursor-pointer">{{ t('settings.bindModeAll') }}</Label>
|
||||||
|
<p class="text-xs text-muted-foreground mt-0.5">{{ t('settings.bindModeAllDesc') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="bindMode === 'all'" class="ml-7 flex items-center justify-between rounded-md border border-dashed px-3 py-2">
|
||||||
|
<Label class="text-sm font-normal">{{ t('settings.bindIpv6') }}</Label>
|
||||||
<Switch v-model="bindAllIpv6" />
|
<Switch v-model="bindAllIpv6" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="bindMode === 'loopback'" class="flex items-center justify-between">
|
|
||||||
<div class="space-y-0.5">
|
|
||||||
<Label>{{ t('settings.bindIpv6') }}</Label>
|
|
||||||
<p class="text-xs text-muted-foreground">{{ t('settings.bindLocalDesc') }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<!-- Loopback only -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<RadioGroupItem value="loopback" id="bind-loopback" class="mt-0.5" />
|
||||||
|
<div class="flex-1">
|
||||||
|
<Label for="bind-loopback" class="cursor-pointer">{{ t('settings.bindModeLocal') }}</Label>
|
||||||
|
<p class="text-xs text-muted-foreground mt-0.5">{{ t('settings.bindModeLocalDesc') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="bindMode === 'loopback'" class="ml-7 flex items-center justify-between rounded-md border border-dashed px-3 py-2">
|
||||||
|
<Label class="text-sm font-normal">{{ t('settings.bindIpv6') }}</Label>
|
||||||
<Switch v-model="bindLocalIpv6" />
|
<Switch v-model="bindLocalIpv6" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="bindMode === 'custom'" class="space-y-2">
|
<Separator />
|
||||||
<Label>{{ t('settings.bindAddressList') }}</Label>
|
|
||||||
|
<!-- Custom addresses -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<RadioGroupItem value="custom" id="bind-custom" class="mt-0.5" />
|
||||||
|
<div class="flex-1">
|
||||||
|
<Label for="bind-custom" class="cursor-pointer">{{ t('settings.bindModeCustom') }}</Label>
|
||||||
|
<p class="text-xs text-muted-foreground mt-0.5">{{ t('settings.bindModeCustomDesc') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="bindMode === 'custom'" class="ml-7 space-y-2">
|
||||||
<div v-for="(_, i) in bindAddressList" :key="`bind-${i}`" class="flex gap-2">
|
<div v-for="(_, i) in bindAddressList" :key="`bind-${i}`" class="flex gap-2">
|
||||||
<Input v-model="bindAddressList[i]" placeholder="192.168.1.10" />
|
<Input v-model="bindAddressList[i]" placeholder="192.168.1.10" />
|
||||||
<Button variant="ghost" size="icon" :aria-label="t('common.delete')" @click="removeBindAddress(i)">
|
<Button variant="ghost" size="icon" :aria-label="t('common.delete')" @click="removeBindAddress(i)">
|
||||||
@@ -2659,41 +2935,95 @@ watch(() => route.query.tab, (tab) => {
|
|||||||
<Plus class="h-4 w-4 mr-1" />
|
<Plus class="h-4 w-4 mr-1" />
|
||||||
{{ t('settings.addBindAddress') }}
|
{{ t('settings.addBindAddress') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
<p class="text-xs text-muted-foreground">{{ t('settings.bindAddressListDesc') }}</p>
|
|
||||||
<p v-if="bindAddressError" class="text-xs text-destructive">{{ bindAddressError }}</p>
|
<p v-if="bindAddressError" class="text-xs text-destructive">{{ bindAddressError }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
<div class="flex justify-end pt-4">
|
<!-- Effective addresses preview -->
|
||||||
<Button @click="saveWebServerConfig" :disabled="webServerLoading || !!bindAddressError">
|
<div v-if="effectiveBindAddresses.length > 0" class="flex items-center gap-2 rounded-md bg-muted px-3 py-2 text-sm">
|
||||||
<Save class="h-4 w-4 mr-2" />
|
<span class="text-muted-foreground shrink-0">{{ t('settings.effectiveAddresses') }}:</span>
|
||||||
{{ t('common.save') }}
|
<span class="font-mono text-xs break-all">{{ effectiveBindAddresses.join(', ') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between pt-2">
|
||||||
|
<p class="text-xs text-muted-foreground">⚠ {{ t('settings.restartRequired') }}</p>
|
||||||
|
<Button @click="saveWebServerConfig" :disabled="webServerLoading || !!bindAddressError || autoRestarting">
|
||||||
|
<RefreshCw v-if="autoRestarting" class="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
<Save v-else class="h-4 w-4 mr-2" />
|
||||||
|
{{ autoRestarting ? t('settings.restarting') : t('common.save') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<!-- SSL Certificate Card -->
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader class="flex flex-row items-start justify-between space-y-0 pb-3">
|
||||||
<CardTitle>{{ t('settings.authSettings') }}</CardTitle>
|
<div class="space-y-1.5">
|
||||||
<CardDescription>{{ t('settings.authSettingsDesc') }}</CardDescription>
|
<CardTitle>{{ t('settings.sslCertificate') }}</CardTitle>
|
||||||
|
<CardDescription>{{ t('settings.sslCertificateDesc') }}</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge :variant="webServerConfig.has_custom_cert ? 'default' : 'secondary'" class="mt-1 shrink-0">
|
||||||
|
{{ webServerConfig.has_custom_cert ? t('settings.sslCertCustom') : t('settings.sslCertSelfSigned') }}
|
||||||
|
</Badge>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-4">
|
<CardContent class="space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<!-- Active custom cert notice -->
|
||||||
<div class="space-y-0.5">
|
<div
|
||||||
<Label>{{ t('settings.allowMultipleSessions') }}</Label>
|
v-if="webServerConfig.has_custom_cert"
|
||||||
<p class="text-xs text-muted-foreground">{{ t('settings.allowMultipleSessionsDesc') }}</p>
|
class="flex items-center justify-between rounded-md border border-emerald-200 bg-emerald-50 dark:bg-emerald-950/20 dark:border-emerald-800 px-3 py-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 text-sm text-emerald-700 dark:text-emerald-400">
|
||||||
|
<Check class="h-4 w-4 shrink-0" />
|
||||||
|
{{ t('settings.sslCertActive') }}
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Button
|
||||||
v-model="authConfig.single_user_allow_multiple_sessions"
|
variant="ghost"
|
||||||
:disabled="authConfigLoading"
|
size="sm"
|
||||||
|
class="text-destructive hover:text-destructive h-7 text-xs"
|
||||||
|
:disabled="certClearing || autoRestarting"
|
||||||
|
@click="clearCertificate"
|
||||||
|
>
|
||||||
|
<RefreshCw v-if="certClearing || autoRestarting" class="h-3 w-3 mr-1 animate-spin" />
|
||||||
|
<Trash2 v-else class="h-3 w-3 mr-1" />
|
||||||
|
{{ autoRestarting ? t('settings.restarting') : t('settings.sslCertClear') }}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Certificate textarea -->
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>{{ t('settings.sslCertPem') }}</Label>
|
||||||
|
<Textarea
|
||||||
|
v-model="sslCertPem"
|
||||||
|
:placeholder="t('settings.sslCertPemPlaceholder')"
|
||||||
|
class="font-mono text-xs min-h-[110px] resize-y"
|
||||||
|
spellcheck="false"
|
||||||
|
autocomplete="off"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
|
||||||
<p class="text-xs text-muted-foreground">{{ t('settings.singleUserSessionNote') }}</p>
|
<!-- Key textarea -->
|
||||||
<div class="flex justify-end pt-2">
|
<div class="space-y-2">
|
||||||
<Button @click="saveAuthConfig" :disabled="authConfigLoading">
|
<Label>{{ t('settings.sslKeyPem') }}</Label>
|
||||||
<Save class="h-4 w-4 mr-2" />
|
<Textarea
|
||||||
{{ t('common.save') }}
|
v-model="sslKeyPem"
|
||||||
|
:placeholder="t('settings.sslKeyPemPlaceholder')"
|
||||||
|
class="font-mono text-xs min-h-[110px] resize-y"
|
||||||
|
spellcheck="false"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between pt-1">
|
||||||
|
<p class="text-xs text-muted-foreground">⚠ {{ t('settings.restartRequired') }}</p>
|
||||||
|
<Button
|
||||||
|
:disabled="certSaving || autoRestarting || !sslCertPem.trim() || !sslKeyPem.trim()"
|
||||||
|
@click="saveCertificate"
|
||||||
|
>
|
||||||
|
<RefreshCw v-if="certSaving || autoRestarting" class="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
<Save v-else class="h-4 w-4 mr-2" />
|
||||||
|
{{ autoRestarting ? t('settings.restarting') : t('settings.sslCertSave') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -3095,7 +3425,7 @@ watch(() => route.query.tab, (tab) => {
|
|||||||
v-if="!isExtRunning(extensions?.gostc?.status)"
|
v-if="!isExtRunning(extensions?.gostc?.status)"
|
||||||
size="sm"
|
size="sm"
|
||||||
@click="startExtension('gostc')"
|
@click="startExtension('gostc')"
|
||||||
:disabled="extensionsLoading || !extConfig.gostc.key"
|
:disabled="extensionsLoading || !extConfig.gostc.key || !extConfig.gostc.addr?.trim()"
|
||||||
>
|
>
|
||||||
<Play class="h-4 w-4 mr-1" />
|
<Play class="h-4 w-4 mr-1" />
|
||||||
{{ t('extensions.start') }}
|
{{ t('extensions.start') }}
|
||||||
@@ -3121,7 +3451,7 @@ watch(() => route.query.tab, (tab) => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||||
<Label class="sm:text-right">{{ t('extensions.gostc.addr') }}</Label>
|
<Label class="sm:text-right">{{ t('extensions.gostc.addr') }}</Label>
|
||||||
<Input v-model="extConfig.gostc.addr" class="sm:col-span-3" placeholder="gostc.mofeng.run" :disabled="isExtRunning(extensions?.gostc?.status)" />
|
<Input v-model="extConfig.gostc.addr" class="sm:col-span-3" :placeholder="t('extensions.gostc.addrPlaceholder')" :disabled="isExtRunning(extensions?.gostc?.status)" />
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
<div class="grid gap-2 sm:grid-cols-4 sm:items-center">
|
||||||
<Label class="sm:text-right">{{ t('extensions.gostc.key') }}</Label>
|
<Label class="sm:text-right">{{ t('extensions.gostc.key') }}</Label>
|
||||||
@@ -3467,7 +3797,11 @@ watch(() => route.query.tab, (tab) => {
|
|||||||
<div class="sm:col-span-3 space-y-1">
|
<div class="sm:col-span-3 space-y-1">
|
||||||
<Input
|
<Input
|
||||||
v-model="rustdeskLocalConfig.relay_key"
|
v-model="rustdeskLocalConfig.relay_key"
|
||||||
type="password"
|
type="text"
|
||||||
|
maxlength="44"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
class="font-mono"
|
||||||
:placeholder="rustdeskStatus?.config?.has_relay_key ? t('extensions.rustdesk.relayKeySet') : t('extensions.rustdesk.relayKeyPlaceholder')"
|
:placeholder="rustdeskStatus?.config?.has_relay_key ? t('extensions.rustdesk.relayKeySet') : t('extensions.rustdesk.relayKeyPlaceholder')"
|
||||||
/>
|
/>
|
||||||
<p class="text-xs text-muted-foreground">{{ t('extensions.rustdesk.relayKeyHint') }}</p>
|
<p class="text-xs text-muted-foreground">{{ t('extensions.rustdesk.relayKeyHint') }}</p>
|
||||||
@@ -3642,12 +3976,12 @@ watch(() => route.query.tab, (tab) => {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<div class="flex justify-between items-center py-2 border-b">
|
<div class="flex justify-between items-center py-2 border-b gap-2">
|
||||||
<span class="text-sm text-muted-foreground">{{ t('settings.hostname') }}</span>
|
<span class="text-sm text-muted-foreground shrink-0">{{ t('settings.hostname') }}</span>
|
||||||
<span class="text-sm font-medium">{{ systemStore.deviceInfo.hostname }}</span>
|
<span class="text-sm font-medium truncate">{{ systemStore.deviceInfo.hostname }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-center py-2 border-b">
|
<div class="flex justify-between items-center py-2 border-b gap-2">
|
||||||
<span class="text-sm text-muted-foreground">{{ t('settings.cpuModel') }}</span>
|
<span class="text-sm text-muted-foreground shrink-0">{{ t('settings.cpuModel') }}</span>
|
||||||
<span class="text-sm font-medium truncate max-w-[60%] text-right">{{ systemStore.deviceInfo.cpu_model }}</span>
|
<span class="text-sm font-medium truncate max-w-[60%] text-right">{{ systemStore.deviceInfo.cpu_model }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-center py-2 border-b">
|
<div class="flex justify-between items-center py-2 border-b">
|
||||||
@@ -3674,22 +4008,20 @@ watch(() => route.query.tab, (tab) => {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<p class="text-xs text-muted-foreground text-center">{{ t('settings.builtWith') }}</p>
|
<p class="text-xs text-muted-foreground text-center">@2025-2026 SilentWind</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Save Button (sticky) -->
|
<!-- Save Button (sticky) -->
|
||||||
<div v-if="['video', 'hid', 'msd'].includes(activeSection)" class="sticky bottom-0 pt-4 pb-2 bg-background border-t -mx-6 px-6 lg:-mx-8 lg:px-8">
|
<div v-if="['video', 'hid', 'msd'].includes(activeSection)" class="sticky bottom-0 pt-3 sm:pt-4 pb-2 bg-background border-t -mx-3 px-3 sm:-mx-6 sm:px-6 lg:-mx-8 lg:px-8">
|
||||||
<div class="flex justify-end">
|
<div class="flex items-center justify-between sm:justify-end gap-2 sm:gap-3">
|
||||||
<div class="flex items-center gap-3">
|
<p v-if="activeSection === 'hid' && !isHidFunctionSelectionValid" class="text-xs text-amber-600 dark:text-amber-400 flex-1 min-w-0">
|
||||||
<p v-if="activeSection === 'hid' && !isHidFunctionSelectionValid" class="text-xs text-amber-600 dark:text-amber-400">
|
|
||||||
{{ t('settings.otgFunctionMinWarning') }}
|
{{ t('settings.otgFunctionMinWarning') }}
|
||||||
</p>
|
</p>
|
||||||
<Button :disabled="loading || (activeSection === 'hid' && !isHidFunctionSelectionValid)" @click="saveConfig">
|
<Button class="shrink-0" :disabled="loading || (activeSection === 'hid' && !isHidFunctionSelectionValid)" @click="saveConfig">
|
||||||
<Check v-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ saved ? t('common.success') : t('common.save') }}
|
<Check v-if="saved" class="h-4 w-4 mr-2" /><Save v-else class="h-4 w-4 mr-2" />{{ saved ? t('common.success') : t('common.save') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -3697,17 +4029,17 @@ watch(() => route.query.tab, (tab) => {
|
|||||||
|
|
||||||
<!-- Terminal Dialog -->
|
<!-- Terminal Dialog -->
|
||||||
<Dialog v-model:open="showTerminalDialog">
|
<Dialog v-model:open="showTerminalDialog">
|
||||||
<DialogContent class="w-[95vw] max-w-5xl h-[85dvh] max-h-[720px] p-0 flex flex-col overflow-hidden">
|
<DialogContent class="w-[98vw] sm:w-[95vw] max-w-5xl h-[90dvh] sm:h-[85dvh] max-h-[720px] p-0 flex flex-col overflow-hidden">
|
||||||
<DialogHeader class="px-4 py-3 border-b shrink-0">
|
<DialogHeader class="px-3 sm:px-4 py-2 sm:py-3 border-b shrink-0">
|
||||||
<DialogTitle class="flex items-center justify-between w-full">
|
<DialogTitle class="flex items-center justify-between w-full">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Terminal class="h-5 w-5" />
|
<Terminal class="h-4 w-4 sm:h-5 sm:w-5" />
|
||||||
{{ t('extensions.ttyd.title') }}
|
<span class="text-sm sm:text-base">{{ t('extensions.ttyd.title') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="h-8 w-8 mr-8"
|
class="h-7 w-7 sm:h-8 sm:w-8 mr-6 sm:mr-8"
|
||||||
@click="openTerminalInNewTab"
|
@click="openTerminalInNewTab"
|
||||||
:aria-label="t('extensions.ttyd.openInNewTab')"
|
:aria-label="t('extensions.ttyd.openInNewTab')"
|
||||||
:title="t('extensions.ttyd.openInNewTab')"
|
:title="t('extensions.ttyd.openInNewTab')"
|
||||||
|
|||||||
@@ -4,12 +4,9 @@ import { useRouter } from 'vue-router'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { configApi, streamApi, type EncoderBackendInfo } from '@/api'
|
import { configApi, streamApi, type EncoderBackendInfo } from '@/api'
|
||||||
import {
|
import { formatFpsLabel, toConfigFps } from '@/lib/fps'
|
||||||
supportedLanguages,
|
import LanguageToggleButton from '@/components/LanguageToggleButton.vue'
|
||||||
setLanguage,
|
import BrandMark from '@/components/BrandMark.vue'
|
||||||
getCurrentLanguage,
|
|
||||||
type SupportedLocale,
|
|
||||||
} from '@/i18n'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
@@ -21,12 +18,6 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select'
|
} from '@/components/ui/select'
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from '@/components/ui/dropdown-menu'
|
|
||||||
import {
|
import {
|
||||||
HoverCard,
|
HoverCard,
|
||||||
HoverCardContent,
|
HoverCardContent,
|
||||||
@@ -34,7 +25,6 @@ import {
|
|||||||
} from '@/components/ui/hover-card'
|
} from '@/components/ui/hover-card'
|
||||||
import { Switch } from '@/components/ui/switch'
|
import { Switch } from '@/components/ui/switch'
|
||||||
import {
|
import {
|
||||||
Monitor,
|
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
@@ -44,7 +34,6 @@ import {
|
|||||||
Keyboard,
|
Keyboard,
|
||||||
Check,
|
Check,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
Languages,
|
|
||||||
Puzzle,
|
Puzzle,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
|
|
||||||
@@ -52,14 +41,6 @@ const { t } = useI18n()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
// Language switcher
|
|
||||||
const currentLanguage = ref<SupportedLocale>(getCurrentLanguage())
|
|
||||||
|
|
||||||
function switchLanguage(lang: SupportedLocale) {
|
|
||||||
currentLanguage.value = lang
|
|
||||||
setLanguage(lang)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Steps: 1 = Account, 2 = Audio/Video, 3 = HID, 4 = Extensions
|
// Steps: 1 = Account, 2 = Audio/Video, 3 = HID, 4 = Extensions
|
||||||
const step = ref(1)
|
const step = ref(1)
|
||||||
const totalSteps = 4
|
const totalSteps = 4
|
||||||
@@ -96,14 +77,10 @@ const hidBackend = ref('ch9329')
|
|||||||
const ch9329Port = ref('')
|
const ch9329Port = ref('')
|
||||||
const ch9329Baudrate = ref(9600)
|
const ch9329Baudrate = ref(9600)
|
||||||
const otgUdc = ref('')
|
const otgUdc = ref('')
|
||||||
const hidOtgProfile = ref('full')
|
const hidOtgProfile = ref('full_no_consumer')
|
||||||
const otgMsdEnabled = ref(true)
|
const otgMsdEnabled = ref(true)
|
||||||
const otgEndpointBudget = ref<'five' | 'six' | 'unlimited'>('six')
|
const otgEndpointBudget = ref<'five' | 'six' | 'unlimited'>('six')
|
||||||
const otgKeyboardLeds = ref(true)
|
const otgKeyboardLeds = ref(true)
|
||||||
const otgProfileTouched = ref(false)
|
|
||||||
const otgEndpointBudgetTouched = ref(false)
|
|
||||||
const otgKeyboardLedsTouched = ref(false)
|
|
||||||
const showAdvancedOtg = ref(false)
|
|
||||||
|
|
||||||
// Extension settings
|
// Extension settings
|
||||||
const ttydEnabled = ref(false)
|
const ttydEnabled = ref(false)
|
||||||
@@ -237,58 +214,18 @@ const otgRequiredEndpoints = computed(() => {
|
|||||||
return endpoints
|
return endpoints
|
||||||
})
|
})
|
||||||
|
|
||||||
const otgProfileHasKeyboard = computed(() =>
|
|
||||||
hidOtgProfile.value === 'full'
|
|
||||||
|| hidOtgProfile.value === 'full_no_consumer'
|
|
||||||
|| hidOtgProfile.value === 'legacy_keyboard'
|
|
||||||
)
|
|
||||||
|
|
||||||
const isOtgEndpointBudgetValid = computed(() => {
|
const isOtgEndpointBudgetValid = computed(() => {
|
||||||
const limit = endpointLimitForBudget(otgEndpointBudget.value)
|
const limit = endpointLimitForBudget(otgEndpointBudget.value)
|
||||||
return limit === null || otgRequiredEndpoints.value <= limit
|
return limit === null || otgRequiredEndpoints.value <= limit
|
||||||
})
|
})
|
||||||
|
|
||||||
const otgEndpointUsageText = computed(() => {
|
|
||||||
const limit = endpointLimitForBudget(otgEndpointBudget.value)
|
|
||||||
if (limit === null) {
|
|
||||||
return t('settings.otgEndpointUsageUnlimited', { used: otgRequiredEndpoints.value })
|
|
||||||
}
|
|
||||||
return t('settings.otgEndpointUsage', { used: otgRequiredEndpoints.value, limit })
|
|
||||||
})
|
|
||||||
|
|
||||||
function applyOtgDefaults() {
|
function applyOtgDefaults() {
|
||||||
if (hidBackend.value !== 'otg') return
|
if (hidBackend.value !== 'otg') return
|
||||||
|
|
||||||
const recommendedBudget = defaultOtgEndpointBudgetForUdc(otgUdc.value)
|
otgEndpointBudget.value = defaultOtgEndpointBudgetForUdc(otgUdc.value)
|
||||||
if (!otgEndpointBudgetTouched.value) {
|
|
||||||
otgEndpointBudget.value = recommendedBudget
|
|
||||||
}
|
|
||||||
if (!otgProfileTouched.value) {
|
|
||||||
hidOtgProfile.value = 'full_no_consumer'
|
hidOtgProfile.value = 'full_no_consumer'
|
||||||
}
|
|
||||||
if (!otgKeyboardLedsTouched.value) {
|
|
||||||
otgKeyboardLeds.value = otgEndpointBudget.value !== 'five'
|
otgKeyboardLeds.value = otgEndpointBudget.value !== 'five'
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function onOtgProfileChange(value: unknown) {
|
|
||||||
hidOtgProfile.value = typeof value === 'string' ? value : 'full'
|
|
||||||
otgProfileTouched.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function onOtgEndpointBudgetChange(value: unknown) {
|
|
||||||
otgEndpointBudget.value =
|
|
||||||
value === 'five' || value === 'six' || value === 'unlimited' ? value : 'six'
|
|
||||||
otgEndpointBudgetTouched.value = true
|
|
||||||
if (!otgKeyboardLedsTouched.value) {
|
|
||||||
otgKeyboardLeds.value = otgEndpointBudget.value !== 'five'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onOtgKeyboardLedsChange(value: boolean) {
|
|
||||||
otgKeyboardLeds.value = value
|
|
||||||
otgKeyboardLedsTouched.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Common baud rates for CH9329
|
// Common baud rates for CH9329
|
||||||
const baudRates = [9600, 19200, 38400, 57600, 115200]
|
const baudRates = [9600, 19200, 38400, 57600, 115200]
|
||||||
@@ -412,12 +349,6 @@ watch(otgUdc, () => {
|
|||||||
applyOtgDefaults()
|
applyOtgDefaults()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(showAdvancedOtg, (open) => {
|
|
||||||
if (open) {
|
|
||||||
applyOtgDefaults()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const result = await configApi.listDevices()
|
const result = await configApi.listDevices()
|
||||||
@@ -585,7 +516,7 @@ async function handleSetup() {
|
|||||||
setupData.video_height = height
|
setupData.video_height = height
|
||||||
}
|
}
|
||||||
if (videoFps.value) {
|
if (videoFps.value) {
|
||||||
setupData.video_fps = videoFps.value
|
setupData.video_fps = toConfigFps(videoFps.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HID settings
|
// HID settings
|
||||||
@@ -637,34 +568,12 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
|
|||||||
<Card class="w-full max-w-lg relative">
|
<Card class="w-full max-w-lg relative">
|
||||||
<!-- Language Switcher -->
|
<!-- Language Switcher -->
|
||||||
<div class="absolute top-4 right-4">
|
<div class="absolute top-4 right-4">
|
||||||
<DropdownMenu>
|
<LanguageToggleButton />
|
||||||
<DropdownMenuTrigger as-child>
|
|
||||||
<Button variant="ghost" size="sm" class="gap-2">
|
|
||||||
<Languages class="w-4 h-4" />
|
|
||||||
<span class="text-sm">
|
|
||||||
{{ supportedLanguages.find((l) => l.code === currentLanguage)?.name }}
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
v-for="lang in supportedLanguages"
|
|
||||||
:key="lang.code"
|
|
||||||
:class="{ 'bg-accent': lang.code === currentLanguage }"
|
|
||||||
@click="switchLanguage(lang.code)"
|
|
||||||
>
|
|
||||||
<span class="mr-2">{{ lang.flag }}</span>
|
|
||||||
{{ lang.name }}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<CardHeader class="text-center space-y-2 pt-10 sm:pt-12">
|
<CardHeader class="text-center space-y-2 pt-10 sm:pt-12">
|
||||||
<div
|
<div class="mx-auto flex justify-center">
|
||||||
class="inline-flex items-center justify-center w-16 h-16 mx-auto rounded-full bg-primary/10"
|
<BrandMark size="xl" />
|
||||||
>
|
|
||||||
<Monitor class="w-8 h-8 text-primary" />
|
|
||||||
</div>
|
</div>
|
||||||
<CardTitle class="text-xl sm:text-2xl">{{ t('setup.welcome') }}</CardTitle>
|
<CardTitle class="text-xl sm:text-2xl">{{ t('setup.welcome') }}</CardTitle>
|
||||||
<CardDescription>{{ t('setup.description') }}</CardDescription>
|
<CardDescription>{{ t('setup.description') }}</CardDescription>
|
||||||
@@ -879,7 +788,7 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem v-for="fps in availableFps" :key="fps" :value="fps">
|
<SelectItem v-for="fps in availableFps" :key="fps" :value="fps">
|
||||||
{{ fps }} FPS
|
{{ formatFpsLabel(fps) }}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -1039,78 +948,6 @@ const stepIcons = [User, Video, Keyboard, Puzzle]
|
|||||||
{{ t('setup.noUdcDevices') }}
|
{{ t('setup.noUdcDevices') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-2 border rounded-lg">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="w-full flex items-center justify-between p-3 text-left hover:bg-muted/50 rounded-lg transition-colors"
|
|
||||||
:aria-label="t('setup.advancedOtg')"
|
|
||||||
@click="showAdvancedOtg = !showAdvancedOtg"
|
|
||||||
>
|
|
||||||
<span class="text-sm font-medium">
|
|
||||||
{{ t('setup.otgAdvanced') }} ({{ t('common.optional') }})
|
|
||||||
</span>
|
|
||||||
<ChevronRight
|
|
||||||
class="h-4 w-4 transition-transform duration-200"
|
|
||||||
:class="{ 'rotate-90': showAdvancedOtg }"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<div v-if="showAdvancedOtg" class="px-3 pb-3 space-y-3">
|
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
{{ t('setup.otgProfileDesc') }}
|
|
||||||
</p>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="otgProfile">{{ t('setup.otgProfile') }}</Label>
|
|
||||||
<Select :model-value="hidOtgProfile" @update:modelValue="onOtgProfileChange">
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="full">{{ t('settings.otgProfileFull') }}</SelectItem>
|
|
||||||
<SelectItem value="full_no_consumer">{{ t('settings.otgProfileFullNoConsumer') }}</SelectItem>
|
|
||||||
<SelectItem value="legacy_keyboard">{{ t('settings.otgProfileLegacyKeyboard') }}</SelectItem>
|
|
||||||
<SelectItem value="legacy_mouse_relative">{{ t('settings.otgProfileLegacyMouseRelative') }}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label for="otgEndpointBudget">{{ t('settings.otgEndpointBudget') }}</Label>
|
|
||||||
<Select :model-value="otgEndpointBudget" @update:modelValue="onOtgEndpointBudgetChange">
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="five">5</SelectItem>
|
|
||||||
<SelectItem value="six">6</SelectItem>
|
|
||||||
<SelectItem value="unlimited">{{ t('settings.otgEndpointBudgetUnlimited') }}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
{{ otgEndpointUsageText }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between rounded-md border border-border/60 p-3">
|
|
||||||
<div>
|
|
||||||
<Label>{{ t('settings.otgKeyboardLeds') }}</Label>
|
|
||||||
<p class="text-xs text-muted-foreground">{{ t('settings.otgKeyboardLedsDesc') }}</p>
|
|
||||||
</div>
|
|
||||||
<Switch :model-value="otgKeyboardLeds" :disabled="!otgProfileHasKeyboard" @update:model-value="onOtgKeyboardLedsChange" />
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between rounded-md border border-border/60 p-3">
|
|
||||||
<div>
|
|
||||||
<Label>{{ t('settings.otgFunctionMsd') }}</Label>
|
|
||||||
<p class="text-xs text-muted-foreground">{{ t('settings.otgFunctionMsdDesc') }}</p>
|
|
||||||
</div>
|
|
||||||
<Switch v-model="otgMsdEnabled" />
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-muted-foreground">
|
|
||||||
{{ t('settings.otgEndpointBudgetHint') }}
|
|
||||||
</p>
|
|
||||||
<p v-if="!isOtgEndpointBudgetValid" class="text-xs text-amber-600 dark:text-amber-400">
|
|
||||||
{{ t('settings.otgEndpointExceeded', { used: otgRequiredEndpoints, limit: otgEndpointBudget === 'unlimited' ? t('settings.otgEndpointBudgetUnlimited') : otgEndpointBudget === 'five' ? '5' : '6' }) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user