mirror of
https://github.com/SteamFramelyHomebrew/framely.git
synced 2026-10-18 16:01:27 +02:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f9471c3e4 | ||
|
|
1b847f9305 | ||
|
|
d229ad2b09 | ||
|
|
69831e474e | ||
|
|
a078e8bfb1 | ||
|
|
926ee38b96 |
No files matched your search
Generated
+1
-1
@@ -425,7 +425,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "framely"
|
||||
version = "0.8.0-preview.2"
|
||||
version = "0.8.2-preview.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "framely"
|
||||
version = "0.8.0-preview.2"
|
||||
version = "0.8.2-preview.1"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
## 采集与网页观看
|
||||
|
||||
“网页观看”和快捷面板的串流卡片均有 **开始/停止录制** 按钮,状态同步。录制复用当前原始 H.264/H.265 + 可选 Opus 串流,不进行二次编码,文件保存到 Steam 用户的 `Videos/framely`(头显默认 `/home/steamos/Videos/framely`),使用唯一文件名和 MKV 容器,不覆盖已有视频。未串流时开始录制会先启动串流;停止录制不停止串流,停止串流会先结束录制并写入文件尾。调整取景前必须停止录制。
|
||||
|
||||
录制期间头显右上角显示红点、计时和停止图标。指向指示器并点击可停止当前录制;显示指示器不请求场景输入焦点或开启全局激光模式。录制进程异常退出时取消指示并在面板显示错误,已经写入的视频保留。正常关闭会话时尝试完成文件收尾;突然断电无法保证最后一段数据完整。
|
||||
|
||||
**屏幕采集**读取头显实际扫描输出,包括透视、Steam 菜单和空间窗口。核心 root 服务只启动固定的 `framely-panel-grab` 助手,通过受 Steam 用户身份检查的私人 Unix socket 导出 DRM framebuffer 的 DMA-BUF。其余采集、Vulkan 畸变校正、取景及 Iris H.264/H.265 编码均以 Steam 用户运行;不设置应用 capability,也不依赖 Framecorder 插件。
|
||||
|
||||
Framecorder 原插件的名称是“面板画面”和“SteamVR 备用画面”(CLI 为 `panel` / `headset`)。Framely 使用“屏幕采集”和“SteamVR”,避免把参考实现的应用名当作采集方式。
|
||||
@@ -16,6 +20,8 @@ Framecorder 原插件的名称是“面板画面”和“SteamVR 备用画面”
|
||||
|
||||
网页优先协商用户选择的 H.264 或 H.265。当前官方 CEF 运行包不能解码所选编码时,头显自带 FFmpeg 以 libvpx 提供 VP8 + Opus 兼容输出;所有需要 VP8 的观看者共享一个额外的软件编码进程。采集来源不变。支持所选编码的观看者直接接收原串流,DLNA 仍使用 H.264。VP8 增加 CPU 消耗;兼容观看结束后收回该进程。Framely 不安装、调用或依赖外部 Chrome。
|
||||
|
||||
在“在其他设备上观看”中开启 **免验证**,显示和复制的观看地址会附带随机凭证。持有该链接的人无需管理密码即可观看,但不能访问管理面板、文件或终端。链接在停止/重新开始串流及重启后仍有效;关闭开关会撤销链接并断开免验证观看,已登录观看不受影响。再次开启生成新链接。网络访问总开关仍需开启。
|
||||
|
||||
声音使用小片段和独立的有界输入队列,避免音频阻塞拖慢视频;编码使用无 B 帧和低延迟参数。SteamVR 的 H.265 是软件编码,会增加 CPU 负载;屏幕采集使用 Iris 硬件编码。
|
||||
|
||||
## 接收与发送
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use anyhow::{bail, Result};
|
||||
|
||||
use framely_capture::openvr::OpenVr;
|
||||
use crate::panel_geometry::PanelGeometry;
|
||||
|
||||
/// Output pixels between grid points. The mapping is smooth, 8 is plenty.
|
||||
const STEP: u32 = 8;
|
||||
@@ -113,6 +114,7 @@ struct EyeModel {
|
||||
t: f64,
|
||||
b: f64,
|
||||
roll: f64,
|
||||
panel_geometry: Option<PanelGeometry>,
|
||||
/// Panel -> render target, sampled on a grid, per channel.
|
||||
forward: Vec<[[f64; 2]; 3]>,
|
||||
/// Render target spots the compositor never draws.
|
||||
@@ -125,6 +127,20 @@ impl EyeModel {
|
||||
fn new(vr: &OpenVr, eye: usize) -> Result<Self> {
|
||||
let proj = vr.projection(eye);
|
||||
let roll = vr.eye_roll(eye);
|
||||
// Parallel render cameras report identity even though the physical
|
||||
// Frame panels are clocked. Do not apply both transforms twice.
|
||||
let panel_geometry = if roll.abs() < 1e-5 {
|
||||
match PanelGeometry::load(eye) {
|
||||
Ok(geometry) => {
|
||||
log::info!("eye {eye}: experimental factory panel orientation correction enabled");
|
||||
Some(geometry)
|
||||
}
|
||||
Err(error) => {
|
||||
log::warn!("eye {eye}: factory panel orientation unavailable, retaining OpenVR mapping: {error}");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else { None };
|
||||
log::info!(
|
||||
"eye {eye} projection: {proj:?}, render target {:?}, roll {:.2}°",
|
||||
vr.render_target_size(),
|
||||
@@ -139,13 +155,21 @@ impl EyeModel {
|
||||
for j in 0..n {
|
||||
for i in 0..n {
|
||||
let (u, v) = (i as f32 / (n - 1) as f32, j as f32 / (n - 1) as f32);
|
||||
if let Some(d) = vr.distortion(eye, u, v) {
|
||||
forward[j * n + i] = d.map(|c| [c[0] as f64, c[1] as f64]);
|
||||
let (du, dv) = if panel_geometry.is_some() { (1. - u, 1. - v) } else { (u, v) };
|
||||
if let Some(d) = vr.distortion(eye, du, dv) {
|
||||
forward[j * n + i] = d.map(|c| if panel_geometry.is_some() {
|
||||
[1. - c[0] as f64, 1. - c[1] as f64]
|
||||
} else { [c[0] as f64, c[1] as f64] });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let triangles = vr.hidden_area(eye);
|
||||
let mut triangles = vr.hidden_area(eye);
|
||||
if panel_geometry.is_some() {
|
||||
for triangle in &mut triangles {
|
||||
for point in triangle { *point = [1. - point[0], 1. - point[1]]; }
|
||||
}
|
||||
}
|
||||
let hidden = rasterize(&triangles);
|
||||
if log::log_enabled!(log::Level::Debug) {
|
||||
let rows: Vec<String> = (0..32)
|
||||
@@ -168,6 +192,7 @@ impl EyeModel {
|
||||
t: proj.top as f64,
|
||||
b: proj.bottom as f64,
|
||||
roll,
|
||||
panel_geometry,
|
||||
forward,
|
||||
hidden,
|
||||
covered,
|
||||
@@ -177,8 +202,12 @@ impl EyeModel {
|
||||
/// Render target UV for a view direction given as tangents, with the
|
||||
/// eye's roll undone so the video stays level with the head.
|
||||
fn target(&self, tx: f64, ty: f64) -> [f64; 2] {
|
||||
let (s, c) = (-self.roll).sin_cos();
|
||||
let (rx, ry) = (tx * c - ty * s, tx * s + ty * c);
|
||||
let [rx, ry] = if let Some(geometry) = self.panel_geometry {
|
||||
geometry.tangents(tx, ty)
|
||||
} else {
|
||||
let (s, c) = (-self.roll).sin_cos();
|
||||
[tx * c - ty * s, tx * s + ty * c]
|
||||
};
|
||||
[(rx - self.l) / (self.r - self.l), (ry - self.t) / (self.b - self.t)]
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ mod encoder;
|
||||
mod gpu;
|
||||
mod kms;
|
||||
mod lut;
|
||||
mod panel_geometry;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Physical panel orientation is distinct from OpenVR's parallel render cameras.
|
||||
//! Steam Frame exposes the former in its per-device factory calibration.
|
||||
|
||||
use anyhow::{ensure, Context, Result};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct PanelGeometry {
|
||||
eye_to_head: [[f64; 3]; 3],
|
||||
}
|
||||
|
||||
impl PanelGeometry {
|
||||
pub fn load(eye: usize) -> Result<Self> {
|
||||
let path = "/persist/device_config.json";
|
||||
ensure!(std::fs::metadata(path)?.len() <= 1024 * 1024, "oversized panel calibration");
|
||||
Self::parse(&std::fs::read_to_string(path)?, eye)
|
||||
}
|
||||
|
||||
fn parse(json: &str, eye: usize) -> Result<Self> {
|
||||
let config: serde_json::Value = serde_json::from_str(json)?;
|
||||
// Factory JSON retains the internal model name on retail devices.
|
||||
ensure!(matches!(config["model_number"].as_str(), Some("Steam Frame" | "Deckard MP")), "not Steam Frame calibration");
|
||||
let matrix = &config["tracking_to_eye_transform"][eye]["eye_to_head"];
|
||||
let mut rotation = [[0.; 3]; 3];
|
||||
for (i, row) in rotation.iter_mut().enumerate() {
|
||||
for (j, value) in row.iter_mut().enumerate() {
|
||||
let raw = matrix[i][j].as_f64().context("missing eye rotation")?;
|
||||
ensure!(raw.is_finite(), "non-finite eye rotation");
|
||||
// The shader already rotates each physical panel by 180°.
|
||||
*value = raw * if j < 2 { -1. } else { 1. };
|
||||
}
|
||||
}
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
let dot: f64 = (0..3).map(|k| rotation[i][k] * rotation[j][k]).sum();
|
||||
ensure!((dot - if i == j { 1. } else { 0. }).abs() < 0.01, "invalid eye rotation");
|
||||
}
|
||||
}
|
||||
let determinant = rotation[0][0] * (rotation[1][1] * rotation[2][2] - rotation[1][2] * rotation[2][1])
|
||||
- rotation[0][1] * (rotation[1][0] * rotation[2][2] - rotation[1][2] * rotation[2][0])
|
||||
+ rotation[0][2] * (rotation[1][0] * rotation[2][1] - rotation[1][1] * rotation[2][0]);
|
||||
ensure!((determinant - 1.).abs() < 0.01 && rotation[2][2] > 0.5, "invalid panel basis");
|
||||
Ok(Self { eye_to_head: rotation })
|
||||
}
|
||||
|
||||
pub fn tangents(&self, tx: f64, ty: f64) -> [f64; 2] {
|
||||
// Head ray -> physical eye ray, using the transpose of its rotation.
|
||||
// OpenVR looks along -Z, while image Y increases downwards.
|
||||
let ray = [tx, -ty, -1.];
|
||||
let q: [f64; 3] = std::array::from_fn(|j| {
|
||||
(0..3).map(|i| self.eye_to_head[i][j] * ray[i]).sum()
|
||||
});
|
||||
if q[2] >= -1e-6 {
|
||||
return [f64::NAN; 2];
|
||||
}
|
||||
[q[0] / -q[2], q[1] / q[2]]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn config(matrix: serde_json::Value) -> String {
|
||||
serde_json::json!({"model_number":"Steam Frame", "tracking_to_eye_transform":[{"eye_to_head":matrix}]}).to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_geometry_removes_scanout_rotation() {
|
||||
let p = PanelGeometry::parse(&config(serde_json::json!([[-1,0,0],[0,-1,0],[0,0,1]])), 0).unwrap();
|
||||
assert_eq!(p.tangents(0.2, -0.3), [0.2, -0.3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_geometry_preserves_factory_roll_direction() {
|
||||
let angle: f64 = -10_f64.to_radians();
|
||||
let (s,c) = angle.sin_cos();
|
||||
let p = PanelGeometry::parse(&config(serde_json::json!([[-c,s,0],[-s,-c,0],[0,0,1]])), 0).unwrap();
|
||||
let t = p.tangents(0.5, 0.);
|
||||
assert!((t[0] - 0.5*c).abs() < 1e-12);
|
||||
assert!((t[1] - 0.5*s).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_geometry_rejects_missing_and_invalid_calibration() {
|
||||
for matrix in [serde_json::json!(null), serde_json::json!([[0,0,0],[0,0,0],[0,0,0]]), serde_json::json!([[1,0,0],[0,-1,0],[0,0,1]])] {
|
||||
assert!(PanelGeometry::parse(&config(matrix), 0).is_err());
|
||||
}
|
||||
assert!(PanelGeometry::parse("{}", 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_geometry_accepts_retail_internal_model_name() {
|
||||
let json = config(serde_json::json!([[-1,0,0],[0,-1,0],[0,0,1]]));
|
||||
assert!(PanelGeometry::parse(&json.replace("Steam Frame", "Deckard MP"), 0).is_ok());
|
||||
assert!(PanelGeometry::parse(&json.replace("Steam Frame", "Other HMD"), 0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_geometry_device_fixture() {
|
||||
let Ok(path) = std::env::var("FRAMELY_PANEL_TEST_FIXTURE") else { return };
|
||||
let json = std::fs::read_to_string(path).unwrap();
|
||||
for eye in 0..2 {
|
||||
let geometry = PanelGeometry::parse(&json, eye).unwrap();
|
||||
let ray = geometry.tangents(0.25, 0.25);
|
||||
assert!(ray.into_iter().all(f64::is_finite));
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-9
@@ -31,6 +31,7 @@
|
||||
#include "brand_mask.h"
|
||||
#include "ui_visibility.h"
|
||||
#include "ui_feedback.h"
|
||||
#include "recording_indicator.h"
|
||||
#include "plugin_navigation.h"
|
||||
#include <GL/gl.h>
|
||||
#include <GL/glx.h>
|
||||
@@ -167,7 +168,11 @@ static std::vector<uint8_t> icon(bool hover,bool active){
|
||||
}
|
||||
|
||||
static std::vector<std::string> closed_casts;
|
||||
static json poll_http(int port){int fd=socket(AF_INET,SOCK_STREAM,0);if(fd<0)throw std::runtime_error("HTTP socket failed");timeval timeout{1,0};setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&timeout,sizeof(timeout));setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&timeout,sizeof(timeout));sockaddr_in addr{};addr.sin_family=AF_INET;addr.sin_port=htons(port);inet_pton(AF_INET,"127.0.0.1",&addr.sin_addr);if(connect(fd,reinterpret_cast<sockaddr*>(&addr),sizeof(addr))){close(fd);throw std::runtime_error("UI agent unavailable");}std::string payload;size_t closed_count=0;{std::lock_guard<std::mutex> lock(visibility_mutex);closed_count=closed_casts.size();payload=json{{"views",visible_views},{"closedCasts",closed_casts}}.dump();}std::string req="POST /host/poll HTTP/1.1\r\nHost: 127.0.0.1:"+std::to_string(port)+"\r\nX-Framely-Native: "+token+"\r\nContent-Type: application/json\r\nContent-Length: "+std::to_string(payload.size())+"\r\nConnection: close\r\n\r\n"+payload;size_t sent=0;while(sent<req.size()){auto n=send(fd,req.data()+sent,req.size()-sent,MSG_NOSIGNAL);if(n<=0){close(fd);throw std::runtime_error("HTTP send failed");}sent+=n;}std::string response;char buffer[4096];ssize_t n;while((n=recv(fd,buffer,sizeof(buffer),0))>0){response.append(buffer,n);if(response.size()>4*1024*1024){close(fd);throw std::runtime_error("HTTP body limit");}}close(fd);auto body=response.find("\r\n\r\n");if(body==std::string::npos||response.find("200 OK")==std::string::npos)throw std::runtime_error("Invalid agent response");auto parsed=json::parse(response.substr(body+4));{std::lock_guard<std::mutex> lock(visibility_mutex);closed_casts.erase(closed_casts.begin(),closed_casts.begin()+std::min(closed_count,closed_casts.size()));}return parsed;}
|
||||
// Protected by visibility_mutex (outgoing request) / queue_mutex (status).
|
||||
static std::string pending_recording_stop;
|
||||
static json recording_state=json::object();
|
||||
static RecordingIndicator recording_indicator;
|
||||
static json poll_http(int port){int fd=socket(AF_INET,SOCK_STREAM,0);if(fd<0)throw std::runtime_error("HTTP socket failed");timeval timeout{1,0};setsockopt(fd,SOL_SOCKET,SO_RCVTIMEO,&timeout,sizeof(timeout));setsockopt(fd,SOL_SOCKET,SO_SNDTIMEO,&timeout,sizeof(timeout));sockaddr_in addr{};addr.sin_family=AF_INET;addr.sin_port=htons(port);inet_pton(AF_INET,"127.0.0.1",&addr.sin_addr);if(connect(fd,reinterpret_cast<sockaddr*>(&addr),sizeof(addr))){close(fd);throw std::runtime_error("UI agent unavailable");}std::string payload;size_t closed_count=0;std::string stop_id;{std::lock_guard<std::mutex> lock(visibility_mutex);closed_count=closed_casts.size();stop_id=pending_recording_stop;payload=json{{"views",visible_views},{"closedCasts",closed_casts},{"stopRecording",stop_id}}.dump();}std::string req="POST /host/poll HTTP/1.1\r\nHost: 127.0.0.1:"+std::to_string(port)+"\r\nX-Framely-Native: "+token+"\r\nContent-Type: application/json\r\nContent-Length: "+std::to_string(payload.size())+"\r\nConnection: close\r\n\r\n"+payload;size_t sent=0;while(sent<req.size()){auto n=send(fd,req.data()+sent,req.size()-sent,MSG_NOSIGNAL);if(n<=0){close(fd);throw std::runtime_error("HTTP send failed");}sent+=n;}std::string response;char buffer[4096];ssize_t n;while((n=recv(fd,buffer,sizeof(buffer),0))>0){response.append(buffer,n);if(response.size()>4*1024*1024){close(fd);throw std::runtime_error("HTTP body limit");}}close(fd);auto body=response.find("\r\n\r\n");if(body==std::string::npos||response.find("200 OK")==std::string::npos)throw std::runtime_error("Invalid agent response");auto parsed=json::parse(response.substr(body+4));{std::lock_guard<std::mutex> lock(visibility_mutex);if(pending_recording_stop==stop_id)pending_recording_stop.clear();closed_casts.erase(closed_casts.begin(),closed_casts.begin()+std::min(closed_count,closed_casts.size()));}return parsed;}
|
||||
// Frame renders the native laser cursor in the default overlay sort group.
|
||||
// Raising a panel above that group can obscure the runtime cursor.
|
||||
// OpenVR owns and renders the laser cursor. Coordinates must remain in the
|
||||
@@ -501,11 +506,12 @@ static void launcher_gaze(vr::IVRInput* input,bool allowed,const bool (&connecte
|
||||
gaze_active=true;
|
||||
}
|
||||
static void entry_actions(vr::VROverlayHandle_t button,bool available){
|
||||
if(!entry_actions_ready)return;auto* input=vr::VRInput();vr::VRActiveActionSet_t sets[2]{};sets[0].ulActionSet=entry_set;sets[1].ulActionSet=gaze_set;
|
||||
const bool pointer_available=available||recording_indicator.active();
|
||||
if(!entry_actions_ready){recording_indicator.cancel_input();return;}auto* input=vr::VRInput();vr::VRActiveActionSet_t sets[2]{};sets[0].ulActionSet=entry_set;sets[1].ulActionSet=gaze_set;
|
||||
// Only the eye action participates in overlay priority. Controller bindings
|
||||
// keep their existing priority so Steam laser and Dock controls are preserved.
|
||||
if(launcher_gaze_allowed()&&!entry_hold.pressed&&!(available&&(entry_pointer.inside||overlays->IsHoverTargetOverlay(button))))sets[1].nPriority=vr::k_nActionSetOverlayGlobalPriorityMin;
|
||||
if(input->UpdateActionState(sets,sizeof(sets[0]),gaze_set?2:1)!=vr::VRInputError_None)return;
|
||||
if(input->UpdateActionState(sets,sizeof(sets[0]),gaze_set?2:1)!=vr::VRInputError_None){recording_indicator.cancel_input();return;}
|
||||
auto now=std::chrono::steady_clock::now();
|
||||
// Dock ownership must be checked before reading confirm edges. Gaze focus
|
||||
// alone does not own a trigger aimed at the entry, even before mouse-down
|
||||
@@ -514,8 +520,8 @@ static void entry_actions(vr::VROverlayHandle_t button,bool available){
|
||||
float stick_x=0,stick_y=0;bool stick_active=false;vr::TrackedDeviceIndex_t stick_device=vr::k_unTrackedDeviceIndexInvalid;
|
||||
for(int hand=0;hand<2;hand++){
|
||||
vr::InputPoseActionData_t pose{};auto device=vr_system->GetTrackedDeviceIndexForControllerRole(hand?vr::TrackedControllerRole_RightHand:vr::TrackedControllerRole_LeftHand);bool hit=available&&entry_pointer.hit(device);
|
||||
bool pose_ok=available&&input->GetPoseActionDataRelativeToNow(aim_action,vr::TrackingUniverseStanding,0,&pose,sizeof(pose),hands[hand])==vr::VRInputError_None&&pose.bActive&&pose.pose.bPoseIsValid;
|
||||
if(available&&!pose_ok&&device!=vr::k_unTrackedDeviceIndexInvalid){
|
||||
bool pose_ok=pointer_available&&input->GetPoseActionDataRelativeToNow(aim_action,vr::TrackingUniverseStanding,0,&pose,sizeof(pose),hands[hand])==vr::VRInputError_None&&pose.bActive&&pose.pose.bPoseIsValid;
|
||||
if(pointer_available&&!pose_ok&&device!=vr::k_unTrackedDeviceIndexInvalid){
|
||||
vr::TrackedDevicePose_t tracked[vr::k_unMaxTrackedDeviceCount]{};vr_system->GetDeviceToAbsoluteTrackingPose(vr::TrackingUniverseStanding,0,tracked,vr::k_unMaxTrackedDeviceCount);
|
||||
char model[256]{};vr_system->GetStringTrackedDeviceProperty(device,vr::Prop_RenderModelName_String,model,sizeof(model));
|
||||
vr::RenderModel_ControllerMode_State_t mode{};vr::RenderModel_ComponentState_t tip{};
|
||||
@@ -523,7 +529,7 @@ static void entry_actions(vr::VROverlayHandle_t button,bool available){
|
||||
pose.pose.mDeviceToAbsoluteTracking=entry_tip_pose(tracked[device].mDeviceToAbsoluteTracking,tip.mTrackingToComponentLocal);pose_ok=true;
|
||||
}
|
||||
}
|
||||
if(pose_ok){auto& m=pose.pose.mDeviceToAbsoluteTracking;vr::VROverlayIntersectionParams_t params{};params.eOrigin=vr::TrackingUniverseStanding;for(int r=0;r<3;r++){params.vSource.v[r]=m.m[r][3];params.vDirection.v[r]=-m.m[r][2];}vr::VROverlayIntersectionResults_t result{};hit=hit||(overlays->IsHoverTargetOverlay(button)&&overlays->ComputeOverlayIntersection(button,¶ms,&result)&&result.vUVs.v[0]>=float(dock_icon_left)/dock_canvas_width&&result.vUVs.v[0]<=float(dock_icon_left+128)/dock_canvas_width);}
|
||||
if(available&&pose_ok){auto& m=pose.pose.mDeviceToAbsoluteTracking;vr::VROverlayIntersectionParams_t params{};params.eOrigin=vr::TrackingUniverseStanding;for(int r=0;r<3;r++){params.vSource.v[r]=m.m[r][3];params.vDirection.v[r]=-m.m[r][2];}vr::VROverlayIntersectionResults_t result{};hit=hit||(overlays->IsHoverTargetOverlay(button)&&overlays->ComputeOverlayIntersection(button,¶ms,&result)&&result.vUVs.v[0]>=float(dock_icon_left)/dock_canvas_width&&result.vUVs.v[0]<=float(dock_icon_left+128)/dock_canvas_width);}
|
||||
|
||||
if(hit)gaze_allowed=false;
|
||||
gaze_connected[hand]=device!=vr::k_unTrackedDeviceIndexInvalid&&vr_system->IsTrackedDeviceConnected(device);
|
||||
@@ -558,6 +564,10 @@ static void entry_actions(vr::VROverlayHandle_t button,bool available){
|
||||
if(kind==0&&!active)entry_analog[hand].held=false;
|
||||
if(kind==0&&active)gaze_buttons[hand]|=down;
|
||||
if(kind==0){
|
||||
// Reuse the same physical tip / analog-trigger fallback as the Dock.
|
||||
// No second action poll or input-focus request for the recording HUD.
|
||||
auto stop_recording=recording_indicator.pointer(hand,pose.pose.mDeviceToAbsoluteTracking,active&&pose_ok,down);
|
||||
if(!stop_recording.empty()){std::lock_guard<std::mutex> lock(visibility_mutex);pending_recording_stop=stop_recording;}
|
||||
auto permission=views.find("framely.apk.permission");
|
||||
if(permission!=views.end()&&!permission->second->closing&&permission->second->shown&&pose_ok){
|
||||
auto& view=*permission->second;
|
||||
@@ -585,7 +595,7 @@ static void entry_actions(vr::VROverlayHandle_t button,bool available){
|
||||
}
|
||||
bool was_held=entry_buttons[hand][kind].held;
|
||||
entry_buttons[hand][kind].update(active,down,hit,now);
|
||||
if(!(gaze_allowed&&gaze_active)&&kind==0&&active&&down&&!was_held&&launcher_open&&!launcher_calibrating&&!hit&&!launcher_dock_interaction()&&launcher_dismiss.allows(now,true)&&views.count("launcher")&&!overlays->IsHoverTargetOverlay(views.at("launcher")->overlay))launcher_outside_at=now+std::chrono::milliseconds(60);
|
||||
if(!(gaze_allowed&&gaze_active)&&kind==0&&active&&down&&!was_held&&launcher_open&&!launcher_calibrating&&!hit&&!recording_indicator.hovered(hand)&&!launcher_dock_interaction()&&launcher_dismiss.allows(now,true)&&views.count("launcher")&&!overlays->IsHoverTargetOverlay(views.at("launcher")->overlay))launcher_outside_at=now+std::chrono::milliseconds(60);
|
||||
}
|
||||
if(launcher_open&&hand==1){vr::InputDigitalActionData_t close{};bool active=input->GetDigitalActionData(close_action,&close,sizeof(close),hands[hand])==vr::VRInputError_None&&close.bActive;bool down=close.bState;
|
||||
if(!active&&device!=vr::k_unTrackedDeviceIndexInvalid&&vr_system->IsTrackedDeviceConnected(device)){char model[256]{};vr_system->GetStringTrackedDeviceProperty(device,vr::Prop_RenderModelName_String,model,sizeof(model));vr::RenderModel_ControllerMode_State_t mode{};vr::RenderModel_ComponentState_t state{};active=vr::VRRenderModels()->GetComponentStateForDevicePath(model,"button_b",hands[hand],&mode,&state);down=active&&(state.uProperties&vr::VRComponentProperty_IsPressed);}
|
||||
@@ -644,10 +654,10 @@ int main(int argc,char** argv){
|
||||
vr::VROverlayHandle_t button=0;auto err=overlays->CreateOverlay("framely.dock.button","Framely",&button);if(err){cef_shutdown();vr::VR_Shutdown();return 6;}auto pixels=dock_icon(false,false);DockIconTexture icon_texture;icon_texture.init();if(!icon_texture.submit(button,pixels)){icon_texture.destroy();overlays->DestroyOverlay(button);cef_shutdown();vr::VR_Shutdown();return 6;}overlays->SetOverlayInputMethod(button,vr::VROverlayInputMethod_Mouse);vr::HmdVector2_t scale{{dock_canvas_width,dock_canvas_height}};overlays->SetOverlayMouseScale(button,&scale);vr::VROverlayIntersectionMaskPrimitive_t mask{};mask.m_nPrimitiveType=vr::OverlayIntersectionPrimitiveType_Rectangle;mask.m_Primitive.m_Rectangle={dock_icon_left,0,128,128};if(overlays->SetOverlayIntersectionMask(button,&mask,1)){std::cerr<<"Dock input mask unavailable\n";overlays->DestroyOverlay(button);icon_texture.destroy();cef_shutdown();vr::VR_Shutdown();return 6;}overlays->SetOverlayFlag(button,vr::VROverlayFlags_VisibleInDashboard,true);overlays->SetOverlayFlag(button,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,true);overlays->SetOverlayFlag(button,vr::VROverlayFlags_SortWithNonSceneOverlays,false);overlays->SetOverlaySortOrder(button,0);overlays->SetOverlayFlag(button,vr::VROverlayFlags_HideLaserIntersection,false);
|
||||
try{create_view("menu","Framely",initial,false,600,840);}catch(const std::exception& e){std::cerr<<e.what()<<"\n";running=0;}
|
||||
const char* steam_root=std::getenv("FRAMELY_STEAM_ROOT");if(!ui_audio.prepare(steam_root?steam_root:""))std::cerr<<"Steam UI sounds unavailable\n";
|
||||
std::thread poller([port]{int failures=0;json last_feedback;json last_gaze;bool gaze_loaded=false;while(running){try{auto data=poll_http(port);notification_badge_count.store(data.value("notificationBadge",0u));launcher_prioritize_ui.store(data.value("launcher",json::object()).value("prioritizeUi",true));std::lock_guard<std::mutex> lock(queue_mutex);auto feedback=data.value("feedback",json::object());if(!feedback.is_object())feedback=json::object();if(feedback!=last_feedback){queue.push_back({{"kind","feedback.settings"},{"haptics",feedback.value("haptics",true)},{"sounds",feedback.value("sounds",false)}});last_feedback=feedback;}auto calibration=data.value("gazeCalibration",json(nullptr));if(!gaze_loaded||calibration!=last_gaze){queue.push_back({{"kind","gaze.correction"},{"calibration",calibration}});last_gaze=calibration;gaze_loaded=true;}for(const auto& c:data.at("commands")){if(queue.size()<256)queue.push_back(c);}failures=0;}catch(const std::exception& e){if(++failures>=10){std::cerr<<"Agent connection lost: "<<e.what()<<"\n";running=0;}}std::this_thread::sleep_for(std::chrono::milliseconds(100));}});
|
||||
std::thread poller([port]{int failures=0;json last_feedback;json last_gaze;bool gaze_loaded=false;while(running){try{auto data=poll_http(port);notification_badge_count.store(data.value("notificationBadge",0u));launcher_prioritize_ui.store(data.value("launcher",json::object()).value("prioritizeUi",true));std::lock_guard<std::mutex> lock(queue_mutex);recording_state=data.value("recording",json::object());if(!recording_state.is_object())recording_state=json::object();auto feedback=data.value("feedback",json::object());if(!feedback.is_object())feedback=json::object();if(feedback!=last_feedback){queue.push_back({{"kind","feedback.settings"},{"haptics",feedback.value("haptics",true)},{"sounds",feedback.value("sounds",false)}});last_feedback=feedback;}auto calibration=data.value("gazeCalibration",json(nullptr));if(!gaze_loaded||calibration!=last_gaze){queue.push_back({{"kind","gaze.correction"},{"calibration",calibration}});last_gaze=calibration;gaze_loaded=true;}for(const auto& c:data.at("commands")){if(queue.size()<256)queue.push_back(c);}failures=0;}catch(const std::exception& e){if(++failures>=10){std::cerr<<"Agent connection lost: "<<e.what()<<"\n";running=0;}}std::this_thread::sleep_for(std::chrono::milliseconds(100));}});
|
||||
std::thread priority_worker([]{LauncherPriority priority;while(running){priority.update(launcher_priority_active.load());std::this_thread::sleep_for(std::chrono::milliseconds(100));}});
|
||||
DockUVState uv{};OverlayPlacement button_placement;bool icon_active=false,icon_hover=false;unsigned icon_badge=0;int icon_progress=0;vr::VROverlayHandle_t last_dock=0;bool was_visible=false;vr::HmdMatrix34_t saved_anchor{},saved_frame{};float saved_size=0;DockCurveEvidence saved_evidence{};auto button_haptic_at=std::chrono::steady_clock::time_point{};auto anchored_at=std::chrono::steady_clock::time_point{};auto sampled_at=std::chrono::steady_clock::time_point{};
|
||||
while(running){auto frame_start=std::chrono::steady_clock::now();cef_do_message_loop_work();commands();vr::VREvent_t global{};while(vr_system->PollNextEvent(&global,sizeof(global))){if(global.eventType==vr::VREvent_Quit){vr_system->AcknowledgeQuit_Exiting();running=0;}observe_dashboard(global);keyboard_event(global);}launcher_priority_active.store(launcher_open&&launcher_prioritize_ui.load());vr::VROverlayHandle_t dock=0;vr::HmdVector2_t mouse_scale{};bool visible=!overlays->FindOverlay("valve.steam.gamepadui.bar",&dock)&&overlays->IsDashboardVisible()&&overlays->IsOverlayVisible(dock)&&!overlays->GetOverlayMouseScale(dock,&mouse_scale);
|
||||
while(running){auto frame_start=std::chrono::steady_clock::now();cef_do_message_loop_work();commands();json recording;{std::lock_guard<std::mutex> lock(queue_mutex);recording=recording_state;}recording_indicator.tick(overlays,recording.value("active",false)?recording.value("id",std::string{}):std::string{},recording.value("elapsedSeconds",uint64_t(0)));vr::VREvent_t global{};while(vr_system->PollNextEvent(&global,sizeof(global))){if(global.eventType==vr::VREvent_Quit){vr_system->AcknowledgeQuit_Exiting();running=0;}observe_dashboard(global);keyboard_event(global);}launcher_priority_active.store(launcher_open&&launcher_prioritize_ui.load());vr::VROverlayHandle_t dock=0;vr::HmdVector2_t mouse_scale{};bool visible=!overlays->FindOverlay("valve.steam.gamepadui.bar",&dock)&&overlays->IsDashboardVisible()&&overlays->IsOverlayVisible(dock)&&!overlays->GetOverlayMouseScale(dock,&mouse_scale);
|
||||
if(visible&&(!was_visible||dock!=last_dock)){uv={};anchored_at={};sampled_at={};}last_dock=dock;
|
||||
// Width and aspect are only ray-solver seeds; physical size comes from intersections.
|
||||
float width=0,height=0;bool fresh=visible&&!overlays->GetOverlayWidthInMeters(dock,&width)&&std::isfinite(width)&&width>0&&width<10&&std::isfinite(mouse_scale.v[0])&&std::isfinite(mouse_scale.v[1])&&mouse_scale.v[0]>0&&mouse_scale.v[1]>0;
|
||||
@@ -802,5 +812,6 @@ int main(int argc,char** argv){
|
||||
// only for immediate API failures, so a broken sync call cannot busy-spin.
|
||||
if(sync!=vr::VROverlayError_None&&sync!=vr::VROverlayError_TimedOut)std::this_thread::sleep_until(frame_start+std::chrono::milliseconds(8));
|
||||
}
|
||||
recording_indicator.destroy();
|
||||
close_keyboard();for(auto&[key,v]:views)close_view(*v);auto deadline=std::chrono::steady_clock::now()+std::chrono::seconds(5);while(std::chrono::steady_clock::now()<deadline){cef_do_message_loop_work();bool all=true;for(auto&[key,v]:views)all&=v->closed;if(all)break;std::this_thread::sleep_for(std::chrono::milliseconds(5));}poller.join();priority_worker.join();for(auto&[key,v]:views)if(!v->closed){std::cerr<<"Browser shutdown timed out\n";_Exit(7);}for(auto&[key,v]:views){overlays->DestroyOverlay(v->overlay);if(v->thumbnail)overlays->DestroyOverlay(v->thumbnail);glDeleteTextures(2,v->textures);}views.clear();overlays->DestroyOverlay(button);icon_texture.destroy();cef_shutdown();vr::VR_Shutdown();glXMakeContextCurrent(xdisplay,None,None,nullptr);glXDestroyContext(xdisplay,glcontext);glXDestroyPbuffer(xdisplay,glsurface);XCloseDisplay(xdisplay);return 0;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <vector>
|
||||
#include <string>
|
||||
#include <cstdio>
|
||||
#include <cmath>
|
||||
#include <algorithm>
|
||||
|
||||
struct RecordingIndicatorClick {
|
||||
bool held=true,armed=false;
|
||||
bool update(bool down,bool inside,bool valid=true){
|
||||
if(!valid){held=true;armed=false;return false;}
|
||||
if(down&&!held)armed=inside;
|
||||
bool clicked=!down&&held&&armed&&inside;
|
||||
if(!down)armed=false;
|
||||
held=down;return clicked;
|
||||
}
|
||||
};
|
||||
|
||||
inline std::vector<unsigned char> recording_indicator_pixels(uint64_t seconds,bool hover){
|
||||
constexpr int w=320,h=80;std::vector<unsigned char> pixels(w*h*4);
|
||||
auto point=[&](int x,int y,int r,int g,int b,int a=255){if(x<0||x>=w||y<0||y>=h)return;auto i=(y*w+x)*4;pixels[i]=r;pixels[i+1]=g;pixels[i+2]=b;pixels[i+3]=a;};
|
||||
for(int y=0;y<h;y++)for(int x=0;x<w;x++){
|
||||
int dx=std::max(12-x,std::max(x-(w-13),0)),dy=std::max(12-y,std::max(y-(h-13),0));
|
||||
if(dx*dx+dy*dy<=144)point(x,y,hover?65:27,hover?45:27,hover?45:29,235);
|
||||
if((x-32)*(x-32)+(y-40)*(y-40)<=11*11)point(x,y,242,61,75);
|
||||
}
|
||||
static constexpr unsigned digits[]={0x7b6f,0x2492,0x73e7,0x73cf,0x5bc9,0x79cf,0x79ef,0x7249,0x7bef,0x7bcf};
|
||||
char text[32];seconds=std::min<uint64_t>(seconds,359999);std::snprintf(text,sizeof text,"%02llu:%02llu:%02llu",(unsigned long long)(seconds/3600),(unsigned long long)(seconds/60%60),(unsigned long long)(seconds%60));
|
||||
int x=70;for(char c:std::string(text)){
|
||||
unsigned glyph=c==':'?0x0410:digits[c-'0'];
|
||||
for(int row=0;row<5;row++)for(int col=0;col<3;col++)if(glyph&(1u<<(14-row*3-col)))for(int dy=0;dy<4;dy++)for(int dx=0;dx<4;dx++)point(x+col*4+dx,30+row*4+dy,245,245,245);
|
||||
x+=20;
|
||||
}
|
||||
for(int y=29;y<51;y++)for(int x=272;x<294;x++)point(x,y,245,245,245);
|
||||
return pixels;
|
||||
}
|
||||
|
||||
class RecordingIndicator {
|
||||
vr::IVROverlay* overlay=nullptr;vr::VROverlayHandle_t handle=0;
|
||||
std::string id;uint64_t rendered=~uint64_t(0);bool last_hover=false;std::array<RecordingIndicatorClick,2> clicks{};std::array<bool,2> hover{};
|
||||
public:
|
||||
void destroy(){if(handle&&overlay)overlay->DestroyOverlay(handle);handle=0;id.clear();hover={};}
|
||||
bool active()const{return handle&&!id.empty();}
|
||||
void cancel_input(){clicks={};hover={};}
|
||||
bool hovered(int hand)const{return hand>=0&&hand<2&&hover[hand];}
|
||||
std::string pointer(int hand,const vr::HmdMatrix34_t& pose,bool valid,bool down){
|
||||
if(!active()||hand<0||hand>=2)return {};
|
||||
vr::VROverlayIntersectionParams_t ray{};ray.eOrigin=vr::TrackingUniverseStanding;
|
||||
for(int k=0;k<3;k++){ray.vSource.v[k]=pose.m[k][3];ray.vDirection.v[k]=-pose.m[k][2];}
|
||||
vr::VROverlayIntersectionResults_t hit{};
|
||||
hover[hand]=valid&&overlay->ComputeOverlayIntersection(handle,&ray,&hit)&&hit.vUVs.v[0]>=0&&hit.vUVs.v[0]<=1&&hit.vUVs.v[1]>=0&&hit.vUVs.v[1]<=1;
|
||||
return clicks[hand].update(down,hover[hand],valid)?id:std::string{};
|
||||
}
|
||||
void tick(vr::IVROverlay* overlays,const std::string& next,uint64_t seconds){
|
||||
overlay=overlays;if(next.empty()){destroy();return;}
|
||||
if(!handle){
|
||||
if(overlay->CreateOverlay("framely.recording","Framely recording — click to stop",&handle))return;
|
||||
overlay->SetOverlayWidthInMeters(handle,.32f);
|
||||
vr::HmdMatrix34_t transform={{{1,0,0,.50f},{0,1,0,.35f},{0,0,1,-1.f}}};
|
||||
overlay->SetOverlayTransformTrackedDeviceRelative(handle,vr::k_unTrackedDeviceIndex_Hmd,&transform);
|
||||
// Stay passive: never take input focus from a game or activate global lasers.
|
||||
overlay->SetOverlayInputMethod(handle,vr::VROverlayInputMethod_None);
|
||||
overlay->SetOverlayFlag(handle,vr::VROverlayFlags_MakeOverlaysInteractiveIfVisible,false);
|
||||
overlay->SetOverlayFlag(handle,vr::VROverlayFlags_HideLaserIntersection,true);
|
||||
overlay->SetOverlayFlag(handle,vr::VROverlayFlags_VisibleInDashboard,true);
|
||||
rendered=~uint64_t(0);
|
||||
}
|
||||
if(id!=next){id=next;clicks={};hover={};}
|
||||
bool highlighted=hover[0]||hover[1];
|
||||
if(seconds!=rendered||highlighted!=last_hover){auto pixels=recording_indicator_pixels(seconds,highlighted);if(!overlay->SetOverlayRaw(handle,pixels.data(),320,80,4)){overlay->ShowOverlay(handle);rendered=seconds;last_hover=highlighted;}}
|
||||
}
|
||||
};
|
||||
@@ -2,5 +2,7 @@
|
||||
//! run as the Steam session user, apart from the framebuffer export helper.
|
||||
pub mod settings;
|
||||
pub mod runtime;
|
||||
mod recording;
|
||||
|
||||
pub mod receiver;
|
||||
pub mod watch_access;
|
||||
@@ -0,0 +1,472 @@
|
||||
//! Mux the existing stream without another capture or video encoder.
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
fs,
|
||||
io::Write,
|
||||
os::unix::{fs::OpenOptionsExt, process::CommandExt},
|
||||
path::{Path, PathBuf},
|
||||
process::{Child, Command, Stdio},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
pub struct Recording {
|
||||
child: Child,
|
||||
path: PathBuf,
|
||||
started: Instant,
|
||||
progress: PathBuf,
|
||||
log: PathBuf,
|
||||
pub id: String,
|
||||
}
|
||||
fn has_media(progress: &str) -> bool {
|
||||
// Stream-copy progress has no `frame=` field on the headset's FFmpeg.
|
||||
let positive = |name: &str| {
|
||||
progress
|
||||
.lines()
|
||||
.rev()
|
||||
.find_map(|line| line.strip_prefix(name))
|
||||
.is_some_and(|value| value.trim().parse::<i64>().is_ok_and(|n| n > 0))
|
||||
};
|
||||
positive("out_time_us=") && positive("total_size=")
|
||||
}
|
||||
impl Recording {
|
||||
pub fn start(rtsp_port: u16, directory: &Path, output: &Path) -> Result<Self> {
|
||||
Self::start_command(Command::new("ffmpeg"), rtsp_port, directory, output)
|
||||
}
|
||||
fn start_command(
|
||||
mut cmd: Command,
|
||||
rtsp_port: u16,
|
||||
directory: &Path,
|
||||
output: &Path,
|
||||
) -> Result<Self> {
|
||||
fs::create_dir_all(output).context("无法创建录制目录")?;
|
||||
let id = hex::encode(rand::random::<[u8; 16]>());
|
||||
let path = output.join(format!(
|
||||
"Framely-{}-{}.mkv",
|
||||
crate::service::now_ms(),
|
||||
&id[..8]
|
||||
));
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&path)?;
|
||||
let progress = directory.join(format!("recording-{id}.progress"));
|
||||
let log = directory.join(format!("recording-{id}.log"));
|
||||
super::runtime::bind_to_session(&mut cmd);
|
||||
let spawned = cmd
|
||||
.process_group(0)
|
||||
.args([
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"warning",
|
||||
"-y",
|
||||
"-fflags",
|
||||
"+genpts",
|
||||
"-analyzeduration",
|
||||
"1000000",
|
||||
"-probesize",
|
||||
"1048576",
|
||||
"-rtsp_transport",
|
||||
"tcp",
|
||||
"-i",
|
||||
&format!("rtsp://127.0.0.1:{rtsp_port}/headset"),
|
||||
"-map",
|
||||
"0:v:0",
|
||||
"-map",
|
||||
"0:a?",
|
||||
"-c",
|
||||
"copy",
|
||||
"-avoid_negative_ts",
|
||||
"make_zero",
|
||||
"-stats_period",
|
||||
"0.2",
|
||||
"-progress",
|
||||
])
|
||||
.arg(&progress)
|
||||
.args(["-flush_packets", "1", "-f", "matroska"])
|
||||
.arg(&path)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.open(&log)?,
|
||||
)
|
||||
.spawn()
|
||||
.context("启动录制程序失败");
|
||||
let child = match spawned {
|
||||
Ok(child) => child,
|
||||
Err(error) => {
|
||||
let _ = fs::remove_file(&path);
|
||||
let _ = fs::remove_file(&log);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let mut recording = Self {
|
||||
child,
|
||||
path,
|
||||
started: Instant::now(),
|
||||
progress,
|
||||
log,
|
||||
id,
|
||||
};
|
||||
let ready = (|| -> Result<()> {
|
||||
let deadline = Instant::now() + Duration::from_secs(12);
|
||||
loop {
|
||||
ensure!(
|
||||
recording.child.try_wait()?.is_none(),
|
||||
"录制程序启动失败:{}",
|
||||
recording.detail()
|
||||
);
|
||||
if fs::read_to_string(&recording.progress)
|
||||
.ok()
|
||||
.is_some_and(|s| has_media(&s))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
ensure!(
|
||||
Instant::now() < deadline,
|
||||
"录制启动超时:{}",
|
||||
recording.detail()
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
})();
|
||||
if let Err(error) = ready {
|
||||
let _ = recording.finish();
|
||||
return Err(error);
|
||||
}
|
||||
Ok(recording)
|
||||
}
|
||||
pub fn status(&self) -> Value {
|
||||
json!({"active":true,"id":self.id,"path":self.path,"elapsedSeconds":self.started.elapsed().as_secs()})
|
||||
}
|
||||
pub fn exited(&mut self) -> Result<bool> {
|
||||
Ok(self.child.try_wait()?.is_some())
|
||||
}
|
||||
pub fn detail(&self) -> String {
|
||||
fs::read_to_string(&self.log)
|
||||
.map(|s| {
|
||||
s.chars()
|
||||
.rev()
|
||||
.take(2048)
|
||||
.collect::<String>()
|
||||
.chars()
|
||||
.rev()
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
pub fn finish(&mut self) -> Result<PathBuf> {
|
||||
if self.child.try_wait()?.is_none() {
|
||||
if let Some(mut stdin) = self.child.stdin.take() {
|
||||
let _ = stdin.write_all(b"q\n");
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(8);
|
||||
while self.child.try_wait()?.is_none() && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
if self.child.try_wait()?.is_none() {
|
||||
unsafe {
|
||||
libc::kill(-(self.child.id() as i32), libc::SIGINT);
|
||||
}
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(2);
|
||||
while self.child.try_wait()?.is_none() && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
if self.child.try_wait()?.is_none() {
|
||||
unsafe {
|
||||
libc::kill(-(self.child.id() as i32), libc::SIGKILL);
|
||||
}
|
||||
let _ = self.child.wait();
|
||||
anyhow::bail!("录制结束超时,已保留可恢复文件:{}", self.path.display());
|
||||
}
|
||||
}
|
||||
let status = self.child.wait()?;
|
||||
ensure!(
|
||||
status.success(),
|
||||
"录制异常结束,文件已保留:{};{}",
|
||||
self.path.display(),
|
||||
self.detail()
|
||||
);
|
||||
Ok(self.path.clone())
|
||||
}
|
||||
}
|
||||
impl Drop for Recording {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.finish();
|
||||
let _ = fs::remove_file(&self.progress);
|
||||
let _ = fs::remove_file(&self.log);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn test_recording(directory: &Path, output: &Path, fail: bool) -> Recording {
|
||||
let script = directory.join("fake-ffmpeg.sh");
|
||||
fs::write(
|
||||
&script,
|
||||
r#"previous=''
|
||||
for argument in "$@"; do
|
||||
if [ "$previous" = '-progress' ]; then progress="$argument"; fi
|
||||
previous="$argument"
|
||||
done
|
||||
printf 'header\n' > "$previous"
|
||||
printf 'out_time_us=1000\ntotal_size=100\n' > "$progress"
|
||||
if [ "$FAIL_LATER" = 1 ]; then sleep 0.4; exit 1; fi
|
||||
read command
|
||||
[ "$command" = q ] || exit 2
|
||||
printf 'trailer\n' >> "$previous"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut command = Command::new("sh");
|
||||
command
|
||||
.arg(script)
|
||||
.env("FAIL_LATER", if fail { "1" } else { "0" });
|
||||
Recording::start_command(command, 1, directory, output).unwrap()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
#[test]
|
||||
fn cast_recording_copy_progress_does_not_require_frame_count() {
|
||||
assert!(has_media(
|
||||
"total_size=38485\nout_time_us=1191811\nprogress=continue\n"
|
||||
));
|
||||
assert!(!has_media("total_size=38485\nout_time_us=N/A\n"));
|
||||
assert!(!has_media("frame=1\ntotal_size=0\nout_time_us=0\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cast_recording_finalizes_and_uses_unique_private_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let output = dir.path().join("videos");
|
||||
let mut first = test_recording(dir.path(), &output, false);
|
||||
let path = first.finish().unwrap();
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), "header\ntrailer\n");
|
||||
assert_eq!(
|
||||
fs::metadata(&path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
let mut second = test_recording(dir.path(), &output, false);
|
||||
assert_ne!(first.id, second.id);
|
||||
assert_ne!(path, second.finish().unwrap());
|
||||
}
|
||||
#[test]
|
||||
fn cast_recording_drop_finalizes_and_cleans_progress() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let output = dir.path().join("videos");
|
||||
let first = test_recording(dir.path(), &output, false);
|
||||
let path = first.path.clone();
|
||||
let progress = first.progress.clone();
|
||||
drop(first);
|
||||
assert_eq!(fs::read_to_string(path).unwrap(), "header\ntrailer\n");
|
||||
assert!(!progress.exists());
|
||||
}
|
||||
#[test]
|
||||
fn cast_recording_spawn_failure_cleans_empty_output() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let output = dir.path().join("videos");
|
||||
assert!(Recording::start_command(
|
||||
Command::new("/framely-no-such-ffmpeg"),
|
||||
1,
|
||||
dir.path(),
|
||||
&output
|
||||
)
|
||||
.is_err());
|
||||
assert_eq!(fs::read_dir(output).unwrap().count(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires FFmpeg, ffprobe and MediaMTX; uses isolated test streams"]
|
||||
fn cast_recording_real_mux_preserves_video_audio_and_finalizes() {
|
||||
struct Worker(Child);
|
||||
impl Drop for Worker {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
let port = || {
|
||||
std::net::TcpListener::bind("127.0.0.1:0")
|
||||
.unwrap()
|
||||
.local_addr()
|
||||
.unwrap()
|
||||
.port()
|
||||
};
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let output = dir.path().join("videos");
|
||||
let rtsp = port();
|
||||
let api = port();
|
||||
let config = dir.path().join("mediamtx.yml");
|
||||
fs::write(&config,format!("logLevel: warn\nrtspAddress: 127.0.0.1:{rtsp}\nrtspTransports: [tcp]\napi: yes\napiAddress: 127.0.0.1:{api}\nrtmp: no\nhls: no\nwebrtc: no\nsrt: no\nmoq: no\npaths:\n headset:\n source: publisher\n")).unwrap();
|
||||
let binary = std::env::var("FRAMELY_MEDIA_MTX").unwrap();
|
||||
let _server = Worker(
|
||||
Command::new(binary)
|
||||
.arg(config)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
let end = Instant::now() + Duration::from_secs(5);
|
||||
while std::net::TcpStream::connect(("127.0.0.1", rtsp)).is_err() {
|
||||
assert!(Instant::now() < end);
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
for (codec, audio) in [("h264", true), ("hevc", true), ("h264", false)] {
|
||||
let mut command = Command::new("ffmpeg");
|
||||
command.args([
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-nostdin",
|
||||
"-re",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"testsrc2=size=160x96:rate=15",
|
||||
]);
|
||||
if audio {
|
||||
command.args([
|
||||
"-re",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=440:sample_rate=48000",
|
||||
]);
|
||||
}
|
||||
command.args([
|
||||
"-c:v",
|
||||
if codec == "hevc" {
|
||||
"libx265"
|
||||
} else {
|
||||
"libx264"
|
||||
},
|
||||
"-preset",
|
||||
"ultrafast",
|
||||
"-tune",
|
||||
"zerolatency",
|
||||
"-threads",
|
||||
"1",
|
||||
"-g",
|
||||
"15",
|
||||
]);
|
||||
if codec == "hevc" {
|
||||
command.args([
|
||||
"-x265-params",
|
||||
"log-level=error:pools=1:frame-threads=1:bframes=0",
|
||||
]);
|
||||
}
|
||||
if audio {
|
||||
command.args(["-c:a", "libopus"]);
|
||||
} else {
|
||||
command.arg("-an");
|
||||
}
|
||||
let address = format!("rtsp://127.0.0.1:{rtsp}/headset");
|
||||
let _publisher = Worker(
|
||||
command
|
||||
.args(["-f", "rtsp", "-rtsp_transport", "tcp", &address])
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
let end = Instant::now() + Duration::from_secs(8);
|
||||
loop {
|
||||
if ureq::get(&format!("http://127.0.0.1:{api}/v3/paths/get/headset"))
|
||||
.timeout(Duration::from_millis(500))
|
||||
.call()
|
||||
.ok()
|
||||
.and_then(|r| r.into_json::<Value>().ok())
|
||||
.is_some_and(|v| v["ready"] == true)
|
||||
{
|
||||
break;
|
||||
}
|
||||
assert!(Instant::now() < end, "publisher not ready");
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
let mut recording = Recording::start(rtsp, dir.path(), &output).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(800));
|
||||
let path = recording.finish().unwrap();
|
||||
let probe = Command::new("ffprobe")
|
||||
.args([
|
||||
"-v",
|
||||
"error",
|
||||
"-show_streams",
|
||||
"-show_format",
|
||||
"-of",
|
||||
"json",
|
||||
])
|
||||
.arg(&path)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
probe.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&probe.stderr)
|
||||
);
|
||||
let data: Value = serde_json::from_slice(&probe.stdout).unwrap();
|
||||
let streams = data["streams"].as_array().unwrap();
|
||||
assert!(
|
||||
streams
|
||||
.iter()
|
||||
.any(|s| s["codec_name"] == codec && s["width"] == 160 && s["height"] == 96),
|
||||
"{data}"
|
||||
);
|
||||
assert_eq!(
|
||||
streams.iter().any(|s| s["codec_name"] == "opus"),
|
||||
audio,
|
||||
"{data}"
|
||||
);
|
||||
assert!(
|
||||
data["format"]["duration"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.parse::<f64>()
|
||||
.unwrap()
|
||||
> 0.5,
|
||||
"{data}"
|
||||
);
|
||||
let decoded = Command::new("ffmpeg")
|
||||
.args(["-v", "error", "-xerror", "-i"])
|
||||
.arg(&path)
|
||||
.args(["-f", "null", "-"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
decoded.status.success(),
|
||||
"saved recording is not decodable: {}",
|
||||
String::from_utf8_lossy(&decoded.stderr)
|
||||
);
|
||||
if audio {
|
||||
let pcm = Command::new("ffmpeg")
|
||||
.args(["-v", "error", "-i"])
|
||||
.arg(&path)
|
||||
.args(["-map", "0:a:0", "-t", "0.2", "-f", "s16le", "pipe:1"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(pcm.status.success());
|
||||
assert!(
|
||||
pcm.stdout
|
||||
.chunks_exact(2)
|
||||
.any(|s| i16::from_le_bytes([s[0], s[1]]).unsigned_abs() > 1000),
|
||||
"recorded audio is silent"
|
||||
);
|
||||
}
|
||||
println!(
|
||||
"PASS: {codec}, audio={audio}, finalized {} bytes",
|
||||
fs::metadata(path).unwrap().len()
|
||||
);
|
||||
drop(recording);
|
||||
drop(_publisher);
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
}
|
||||
+168
-22
@@ -26,6 +26,9 @@ pub struct Casting {
|
||||
pub stream_key: String,
|
||||
error: Option<String>,
|
||||
preview_restore: Option<Option<Settings>>,
|
||||
recording: Option<super::recording::Recording>,
|
||||
recording_error: Option<String>,
|
||||
recording_path: Option<PathBuf>,
|
||||
}
|
||||
fn port() -> Result<u16> {
|
||||
Ok(std::net::TcpListener::bind("127.0.0.1:0")?
|
||||
@@ -50,9 +53,21 @@ impl Casting {
|
||||
stream_key: String::new(),
|
||||
error: None,
|
||||
preview_restore: None,
|
||||
recording: None,
|
||||
recording_error: None,
|
||||
recording_path: None,
|
||||
}
|
||||
}
|
||||
pub fn status(&mut self) -> Value {
|
||||
self.poll_recording();
|
||||
self.poll_workers();
|
||||
let view = fs::File::open(self.directory.join("stream.log")).ok().and_then(|file| { let mut log = String::new(); file.take(65536).read_to_string(&mut log).ok()?; Some(log) }).and_then(|log| log.lines().filter_map(|line|serde_json::from_str::<Value>(line).ok()).find(|v|v["event"]=="view"));
|
||||
json!({"running":self.started.is_some(),"error":self.error,"preview":self.preview_restore.is_some(),"view":view,
|
||||
"watchPath":if self.started.is_some(){Some("/cast/watch")}else{None},
|
||||
"elapsedSeconds":self.started.map(|t|t.elapsed().as_secs()),
|
||||
"codec":self.active_settings.as_ref().map(|s|s.codec.name()),"dominantEye":dominant_eye().ok(),"recording":self.recording_status()})
|
||||
}
|
||||
fn poll_workers(&mut self) {
|
||||
let exited = self
|
||||
.workers
|
||||
.iter_mut()
|
||||
@@ -61,13 +76,43 @@ impl Casting {
|
||||
self.error = Some(format!("串流进程已退出:{code}"));
|
||||
self.stop_workers();
|
||||
}
|
||||
let view = fs::File::open(self.directory.join("stream.log")).ok().and_then(|file| { let mut log = String::new(); file.take(65536).read_to_string(&mut log).ok()?; Some(log) }).and_then(|log| log.lines().filter_map(|line|serde_json::from_str::<Value>(line).ok()).find(|v|v["event"]=="view"));
|
||||
json!({"running":self.started.is_some(),"error":self.error,"preview":self.preview_restore.is_some(),"view":view,
|
||||
"watchPath":if self.started.is_some(){Some("/cast/watch")}else{None},
|
||||
"elapsedSeconds":self.started.map(|t|t.elapsed().as_secs()),
|
||||
"codec":self.active_settings.as_ref().map(|s|s.codec.name()),"dominantEye":dominant_eye().ok()})
|
||||
}
|
||||
pub fn recording_status(&self) -> Value {
|
||||
let mut value = self.recording.as_ref().map(|r|r.status()).unwrap_or(json!({"active":false,"path":self.recording_path}));
|
||||
value["error"] = json!(self.recording_error);
|
||||
value
|
||||
}
|
||||
fn poll_recording(&mut self) {
|
||||
if self.recording.as_mut().is_some_and(|r|r.exited().unwrap_or(true)) {
|
||||
self.recording_error = Some(format!("录制进程意外退出:{}",self.recording.as_ref().unwrap().detail()));
|
||||
let _ = self.stop_recording();
|
||||
}
|
||||
}
|
||||
pub fn start_recording(&mut self, output: &Path) -> Result<Value> {
|
||||
self.status();
|
||||
ensure!(self.started.is_some(), "请先开始串流");
|
||||
ensure!(self.preview_restore.is_none(), "取景预览期间不能录制");
|
||||
ensure!(self.recording.is_none(), "录制已在运行");
|
||||
self.recording_error = None;
|
||||
match super::recording::Recording::start(self.rtsp_port, &self.directory, output) {
|
||||
Ok(recording) => {self.recording = Some(recording); Ok(self.recording_status())},
|
||||
Err(error) => {self.recording_error = Some(format!("{error:#}")); Err(error)}
|
||||
}
|
||||
}
|
||||
pub fn stop_recording(&mut self) -> Result<Value> {
|
||||
if let Some(mut recording) = self.recording.take() {
|
||||
match recording.finish() {
|
||||
Ok(path) => self.recording_path = Some(path),
|
||||
Err(error) => {self.recording_error = Some(format!("{error:#}")); return Err(error);}
|
||||
}
|
||||
}
|
||||
Ok(self.recording_status())
|
||||
}
|
||||
pub fn stop_recording_id(&mut self, id: &str) {
|
||||
if self.recording.as_ref().is_some_and(|r|r.id == id) {let _ = self.stop_recording();}
|
||||
}
|
||||
fn stop_workers(&mut self) {
|
||||
let _ = self.stop_recording();
|
||||
for child in &mut self.workers {
|
||||
if child.try_wait().ok().flatten().is_some() {
|
||||
continue;
|
||||
@@ -100,6 +145,12 @@ impl Casting {
|
||||
/// All VP8 viewers share one on-demand compatibility encoder.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_rtsp_port(&self) -> u16 {self.rtsp_port}
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_whep_server(&mut self, port: u16) {
|
||||
self.started = Some(Instant::now());
|
||||
self.active_settings = Some(Settings::default());
|
||||
self.whep_port = port;
|
||||
}
|
||||
pub fn codec(&self) -> Codec { self.active_settings.as_ref().map(|s|s.codec).unwrap_or_default() }
|
||||
pub fn ensure_vp8(&mut self) -> Result<()> {
|
||||
ensure!(self.status()["running"] == true, "请先开始头显串流");
|
||||
@@ -189,6 +240,8 @@ impl Casting {
|
||||
}
|
||||
}
|
||||
pub fn maintain(&mut self) {
|
||||
self.poll_recording();
|
||||
self.poll_workers();
|
||||
if self.compatibility_poll.elapsed() < Duration::from_secs(2) {
|
||||
return;
|
||||
}
|
||||
@@ -278,6 +331,7 @@ impl Casting {
|
||||
json!(true)
|
||||
}
|
||||
pub fn start_preview(&mut self, settings: &Settings, socket: &Path) -> Result<Value> {
|
||||
ensure!(self.recording.is_none(), "请先停止录制再调整取景");
|
||||
ensure!(self.preview_restore.is_none(), "取景预览已在运行");
|
||||
let restore = self.active_settings.clone();
|
||||
self.stop_workers();
|
||||
@@ -409,11 +463,11 @@ impl Casting {
|
||||
if let Some(fov) = s.horizontal_fov {
|
||||
command.args(["--fov", &fov.to_string()]);
|
||||
}
|
||||
// Bind signed values to their options: older capture workers'
|
||||
// clap parser treats a separate negative number as a new flag.
|
||||
command.args([
|
||||
"--center-x",
|
||||
&s.center_x.to_string(),
|
||||
"--center-y",
|
||||
&s.center_y.to_string(),
|
||||
format!("--center-x={}", s.center_x),
|
||||
format!("--center-y={}", s.center_y),
|
||||
]);
|
||||
}
|
||||
bind_to_session(&mut command);
|
||||
@@ -452,6 +506,11 @@ impl Casting {
|
||||
"0",
|
||||
"-framerate",
|
||||
&s.fps.to_string(),
|
||||
// Timestamp raw packets before FFmpeg schedules the inputs.
|
||||
// Use the capture frame count rather than arrival wallclock:
|
||||
// pipe reads can batch frames and distort scheduler pacing.
|
||||
"-bsf:v",
|
||||
&video_packet_timestamps(s.fps),
|
||||
"-f",
|
||||
if s.codec == Codec::H265 {"hevc"} else {"h264"},
|
||||
"-i",
|
||||
@@ -460,7 +519,6 @@ impl Casting {
|
||||
} else {
|
||||
ffmpeg.args(["-thread_queue_size", "4", "-f", "v4l2", "-i", "/dev/video99"]);
|
||||
}
|
||||
let audio = s.system_audio || s.microphone;
|
||||
if s.system_audio {
|
||||
add_pulse_input(&mut ffmpeg, "@DEFAULT_MONITOR@");
|
||||
}
|
||||
@@ -468,22 +526,15 @@ impl Casting {
|
||||
add_pulse_input(&mut ffmpeg, "default");
|
||||
}
|
||||
ffmpeg.args(["-map", "0:v:0"]);
|
||||
if s.system_audio && s.microphone {
|
||||
ffmpeg.args([
|
||||
"-filter_complex",
|
||||
"[1:a][2:a]amix=inputs=2:duration=longest:normalize=0[a]",
|
||||
"-map",
|
||||
"[a]",
|
||||
]);
|
||||
} else if audio {
|
||||
ffmpeg.args(["-map", "1:a:0"]);
|
||||
}
|
||||
let audio = map_audio(&mut ffmpeg, s.system_audio, s.microphone);
|
||||
if s.source == Source::Screen {
|
||||
ffmpeg.args(["-c:v", "copy"]);
|
||||
if s.codec==Codec::H265 {
|
||||
if s.codec == Codec::H265 {
|
||||
// Iris HEVC headers omit reliable frame timing. Write the requested
|
||||
// timing without decoding/re-encoding, avoiding false 50 fps playback.
|
||||
ffmpeg.args(["-bsf:v",&format!("hevc_metadata=tick_rate={}:num_ticks_poc_diff_one=1",s.fps)]);
|
||||
ffmpeg.args(["-bsf:v", &format!(
|
||||
"hevc_metadata=tick_rate={}:num_ticks_poc_diff_one=1", s.fps
|
||||
)]);
|
||||
}
|
||||
} else {
|
||||
ffmpeg.args(["-vf", &format!("fps={},scale={}:{}:force_original_aspect_ratio=decrease,pad={}:{}:(ow-iw)/2:(oh-ih)/2,format=yuv420p",s.fps,s.width,s.height,s.width,s.height),
|
||||
@@ -598,6 +649,29 @@ fn add_pulse_input(command: &mut Command, device: &str) {
|
||||
command.args(["-thread_queue_size", "4", "-f", "pulse", "-sample_rate", "48000", "-channels", "2", "-fragment_size", "3840", "-i", device]);
|
||||
}
|
||||
|
||||
// Pulse capture timestamps use the audio server's clock, while raw panel video
|
||||
// starts at zero. Generate audio PTS from the sample count in that same origin.
|
||||
// libopus low-delay mode subtracts its 120-sample pre-roll from packet PTS, so
|
||||
// offset the input by 120 samples and keep the first RTSP packet non-negative.
|
||||
fn map_audio(command: &mut Command, system_audio: bool, microphone: bool) -> bool {
|
||||
let audio = system_audio || microphone;
|
||||
if system_audio && microphone {
|
||||
command.args([
|
||||
"-filter_complex",
|
||||
"[1:a][2:a]amix=inputs=2:duration=longest:normalize=0,asetpts=(N+120)/SR/TB[a]",
|
||||
"-map",
|
||||
"[a]",
|
||||
]);
|
||||
} else if audio {
|
||||
command.args(["-map", "1:a:0", "-af", "asetpts=(N+120)/SR/TB"]);
|
||||
}
|
||||
audio
|
||||
}
|
||||
|
||||
fn video_packet_timestamps(fps: u32) -> String {
|
||||
format!("setts=time_base=1/{fps}:pts=N:dts=N:duration=1")
|
||||
}
|
||||
|
||||
pub fn bind_to_session(command: &mut Command) {
|
||||
// Linux PDEATHSIG follows the spawning thread, not the session process.
|
||||
// HTTP request threads end immediately after returning a response. Worker
|
||||
@@ -609,6 +683,78 @@ pub fn bind_to_session(command: &mut Command) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn audio_mapping_generates_monotonic_timestamps() {
|
||||
let arguments = |system_audio, microphone| {
|
||||
let mut command = Command::new("ffmpeg");
|
||||
let enabled = map_audio(&mut command, system_audio, microphone);
|
||||
let arguments = command
|
||||
.get_args()
|
||||
.map(|argument| argument.to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>();
|
||||
(enabled, arguments)
|
||||
};
|
||||
assert_eq!(arguments(false, false), (false, vec![]));
|
||||
for inputs in [(true, false), (false, true)] {
|
||||
assert_eq!(
|
||||
arguments(inputs.0, inputs.1),
|
||||
(
|
||||
true,
|
||||
vec!["-map", "1:a:0", "-af", "asetpts=(N+120)/SR/TB"]
|
||||
.into_iter()
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
arguments(true, true),
|
||||
(
|
||||
true,
|
||||
vec![
|
||||
"-filter_complex",
|
||||
"[1:a][2:a]amix=inputs=2:duration=longest:normalize=0,asetpts=(N+120)/SR/TB[a]",
|
||||
"-map",
|
||||
"[a]",
|
||||
]
|
||||
.into_iter()
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
)
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn cast_recording_stale_indicator_does_not_stop_new_recording_and_stream_stop_finishes() {
|
||||
let dir=tempfile::tempdir().unwrap();let output=dir.path().join("videos");
|
||||
let mut cast=Casting::new(dir.path().into(),dir.path().into());
|
||||
assert!(cast.start_recording(&output).is_err());
|
||||
cast.started=Some(Instant::now());
|
||||
cast.recording=Some(super::super::recording::test_recording(dir.path(),&output,false));
|
||||
assert!(cast.start_recording(&output).is_err());
|
||||
assert!(cast.start_preview(&Settings::default(),dir.path()).is_err());
|
||||
cast.stop_recording_id("old-recording-id");assert!(cast.recording.is_some());
|
||||
let id=cast.recording.as_ref().unwrap().id.clone();cast.stop_recording_id(&id);
|
||||
assert!(cast.recording.is_none());assert!(cast.started.is_some());
|
||||
cast.recording=Some(super::super::recording::test_recording(dir.path(),&output,false));
|
||||
cast.stop();assert!(cast.recording.is_none());assert!(cast.started.is_none());
|
||||
assert_eq!(fs::read_to_string(cast.recording_path.as_ref().unwrap()).unwrap(),"header\ntrailer\n");
|
||||
}
|
||||
#[test]
|
||||
fn cast_recording_process_failure_keeps_stream_and_reports_error() {
|
||||
let dir=tempfile::tempdir().unwrap();let output=dir.path().join("videos");
|
||||
let mut cast=Casting::new(dir.path().into(),dir.path().into());cast.started=Some(Instant::now());
|
||||
cast.recording=Some(super::super::recording::test_recording(dir.path(),&output,true));
|
||||
let end=Instant::now()+Duration::from_secs(3);
|
||||
while cast.recording.is_some()&&Instant::now()<end {cast.maintain();std::thread::sleep(Duration::from_millis(25));}
|
||||
assert!(cast.recording.is_none());assert!(cast.started.is_some());assert!(cast.recording_error.is_some());
|
||||
}
|
||||
#[test]
|
||||
fn copied_video_packets_receive_frame_timestamps() {
|
||||
assert_eq!(
|
||||
video_packet_timestamps(60),
|
||||
"setts=time_base=1/60:pts=N:dts=N:duration=1"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn media_worker_survives_the_http_request_thread() {
|
||||
let mut child = std::thread::spawn(|| {
|
||||
let mut command = Command::new("sleep");
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
//! Persistent, watch-only capability; deliberately separate from cast settings.
|
||||
use anyhow::{ensure, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{fs, io::Write, os::unix::fs::OpenOptionsExt, path::Path};
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
pub struct Grant {
|
||||
pub token: Option<String>,
|
||||
}
|
||||
impl Grant {
|
||||
pub fn load(root: &Path) -> Result<Self> {
|
||||
let bytes = match fs::read(root.join("cast-watch-access.json")) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let grant: Self = serde_json::from_slice(&bytes)?;
|
||||
ensure!(grant.token.as_ref().is_none_or(|t| t.len() == 64 && t.bytes().all(|b| b.is_ascii_hexdigit())), "Invalid watch access record");
|
||||
Ok(grant)
|
||||
}
|
||||
pub fn save(root: &Path, enabled: bool) -> Result<Self> {
|
||||
let mut grant = Self::load(root)?;
|
||||
if enabled && grant.token.is_none() {
|
||||
grant.token = Some(crate::session::cast_random_key());
|
||||
} else if !enabled {
|
||||
grant.token = None;
|
||||
}
|
||||
let temporary = root.join("cast-watch-access.json.tmp");
|
||||
let mut file = fs::OpenOptions::new().write(true).create(true).truncate(true).mode(0o600).custom_flags(libc::O_NOFOLLOW).open(&temporary)?;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
file.set_permissions(fs::Permissions::from_mode(0o600))?;
|
||||
let result = (|| -> Result<()> {
|
||||
file.write_all(&serde_json::to_vec(&grant)?)?;
|
||||
file.sync_all()?;
|
||||
fs::rename(&temporary, root.join("cast-watch-access.json"))?;
|
||||
fs::File::open(root)?.sync_all()?;
|
||||
Ok(())
|
||||
})();
|
||||
if result.is_err() { let _ = fs::remove_file(&temporary); }
|
||||
result?;
|
||||
Ok(grant)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Access {
|
||||
pub token: Option<String>,
|
||||
pub sessions: std::collections::BTreeMap<String, String>,
|
||||
pub revoked: Vec<String>,
|
||||
}
|
||||
impl Access {
|
||||
pub fn accepts(&self, candidate: &str) -> bool {
|
||||
self.token.as_deref().is_some_and(|token| {
|
||||
token.len() == candidate.len() && token.bytes().zip(candidate.bytes()).fold(0u8, |diff, (a,b)| diff | (a ^ b)) == 0
|
||||
})
|
||||
}
|
||||
pub fn replace(&mut self, token: Option<String>) {
|
||||
if self.token != token {
|
||||
self.revoked.extend(std::mem::take(&mut self.sessions).into_values());
|
||||
self.token = token;
|
||||
}
|
||||
}
|
||||
pub fn path(&self) -> String {
|
||||
match &self.token {
|
||||
Some(token) => format!("/cast/watch?token={token}"),
|
||||
None => "/cast/watch".into(),
|
||||
}
|
||||
}
|
||||
pub fn disconnect_revoked(&mut self) {
|
||||
let client = ureq::AgentBuilder::new().redirects(0).timeout(std::time::Duration::from_secs(2)).build();
|
||||
self.revoked.retain(|target| {
|
||||
match client.delete(target).set("If-Match", "*").call() {
|
||||
Ok(_) | Err(ureq::Error::Status(404 | 410, _)) => false,
|
||||
Err(_) => true,
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
#[test]
|
||||
fn watch_access_persists_rotates_and_preserves_on_failure() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
assert!(Grant::load(root.path()).unwrap().token.is_none());
|
||||
let first = Grant::save(root.path(), true).unwrap().token.unwrap();
|
||||
assert_eq!(first.len(), 64);
|
||||
assert_eq!(Grant::load(root.path()).unwrap().token.as_ref(), Some(&first));
|
||||
assert_eq!(Grant::save(root.path(), true).unwrap().token.as_ref(), Some(&first));
|
||||
assert_eq!(fs::metadata(root.path().join("cast-watch-access.json")).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
fs::create_dir(root.path().join("cast-watch-access.json.tmp")).unwrap();
|
||||
assert!(Grant::save(root.path(), false).is_err());
|
||||
assert_eq!(Grant::load(root.path()).unwrap().token.as_ref(), Some(&first));
|
||||
fs::remove_dir(root.path().join("cast-watch-access.json.tmp")).unwrap();
|
||||
assert!(Grant::save(root.path(), false).unwrap().token.is_none());
|
||||
assert_ne!(Grant::save(root.path(), true).unwrap().token.unwrap(), first);
|
||||
}
|
||||
}
|
||||
@@ -779,6 +779,7 @@ impl Service {
|
||||
| "agreement.revoke"
|
||||
| "network.password.verify"
|
||||
| "network.password.configured"
|
||||
| "cast.watchAccess.get"
|
||||
| "network.password.setup"
|
||||
| "system.uninstall.prepare"
|
||||
),
|
||||
@@ -1120,6 +1121,11 @@ impl Service {
|
||||
self.save()?;
|
||||
Ok(json!(true))
|
||||
}
|
||||
"cast.watchAccess.get" => Ok(serde_json::to_value(crate::cast::watch_access::Grant::load(&self.root)?)?),
|
||||
"cast.watchAccess.save" => {
|
||||
let enabled = p["enabled"].as_bool().context("Missing enabled")?;
|
||||
Ok(serde_json::to_value(crate::cast::watch_access::Grant::save(&self.root, enabled)?)?)
|
||||
}
|
||||
"cast.settings.save" => {
|
||||
let settings: crate::cast::settings::Settings = serde_json::from_value(p)?;
|
||||
settings.validate()?;
|
||||
|
||||
+237
-7
@@ -37,6 +37,7 @@ struct Agent {
|
||||
files: Arc<crate::manager_files::Files>,
|
||||
catalog_cache: Arc<Mutex<std::collections::BTreeMap<String, Value>>>,
|
||||
casting: Mutex<crate::cast::runtime::Casting>,
|
||||
watch_access: Mutex<crate::cast::watch_access::Access>,
|
||||
receivers: Mutex<crate::cast::receiver::Receivers>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
@@ -75,6 +76,25 @@ fn push(q: &Mutex<VecDeque<Value>>, v: Value) {
|
||||
q.push_back(v);
|
||||
}
|
||||
impl Agent {
|
||||
fn watch_cookie(r: &Request) -> Option<String> {
|
||||
r.headers().iter().filter(|h| h.field.equiv("Cookie"))
|
||||
.flat_map(|h| h.value.as_str().split(';'))
|
||||
.find_map(|cookie| cookie.trim().strip_prefix("framely-watch=").map(str::to_owned))
|
||||
}
|
||||
fn watch_resource(path: &str, method: &Method) -> bool {
|
||||
(method == &Method::Get && matches!(path, "/cast/watch" | "/cast/access" | "/assets/app.js" | "/assets/app.css"))
|
||||
|| ((path == "/cast/whep" || path.starts_with("/cast/whep/"))
|
||||
&& matches!(method, Method::Post | Method::Patch | Method::Delete | Method::Options))
|
||||
}
|
||||
fn watch_expired(r: Request) -> Result<()> {
|
||||
r.respond(Response::from_string("<!doctype html><meta charset=\"utf-8\"><title>Framely</title><p>观看链接已失效</p>")
|
||||
.with_status_code(403)
|
||||
.with_header(header("Content-Type", "text/html; charset=utf-8"))
|
||||
.with_header(header("X-Framely-Watch-Expired", "1"))
|
||||
.with_header(header("Cache-Control", "no-store"))
|
||||
.with_header(header("Referrer-Policy", "no-referrer")))?;
|
||||
Ok(())
|
||||
}
|
||||
fn core(&self, m: &str, p: Value) -> Result<Value> {
|
||||
ipc::call(&self.socket, m, p)
|
||||
}
|
||||
@@ -229,8 +249,30 @@ impl Agent {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let configured = remote && self.core("network.password.configured", json!({}))? == true;
|
||||
if remote && (!configured || !self.network_authorized(&r)) {
|
||||
let watch_cookie = Self::watch_cookie(&r);
|
||||
if remote && path == "/cast/watch" && r.method() == &Method::Get {
|
||||
let token = r.url().split_once('?').and_then(|(_, query)| url::form_urlencoded::parse(query.as_bytes()).find(|(key,_)| key == "token").map(|(_,value)| value.into_owned()));
|
||||
if let Some(token) = token {
|
||||
if !self.watch_access.lock().unwrap().accepts(&token) { return Self::watch_expired(r); }
|
||||
r.respond(Response::empty(StatusCode(303))
|
||||
.with_header(header("Location", "/cast/watch"))
|
||||
.with_header(header("Set-Cookie", &format!("framely-watch={token}; Path=/; HttpOnly; SameSite=Strict")))
|
||||
.with_header(header("Cache-Control", "no-store"))
|
||||
.with_header(header("Referrer-Policy", "no-referrer")))?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let manager_authorized = remote && self.network_authorized(&r);
|
||||
let guest = remote && !manager_authorized && Self::watch_resource(&path, r.method())
|
||||
&& watch_cookie.as_ref().is_some_and(|token| self.watch_access.lock().unwrap().accepts(token));
|
||||
if remote && Self::watch_resource(&path, r.method()) && !manager_authorized && !guest
|
||||
&& (watch_cookie.is_some() || path == "/cast/access") { return Self::watch_expired(r); }
|
||||
if remote && path == "/cast/access" && (guest || manager_authorized) {
|
||||
r.respond(Response::empty(StatusCode(204)).with_header(header("Cache-Control", "no-store")))?;
|
||||
return Ok(());
|
||||
}
|
||||
let configured = remote && !guest && self.core("network.password.configured", json!({}))? == true;
|
||||
if remote && !guest && (!configured || !manager_authorized) {
|
||||
let status = self.core("status", json!({}))?;
|
||||
let protected = status["database"]["networkPanel"]["passwordEnabled"]
|
||||
.as_bool()
|
||||
@@ -366,6 +408,9 @@ impl Agent {
|
||||
r.as_reader().take(32769).read_to_end(&mut bytes)?;
|
||||
ensure!(bytes.len() <= 32768, "Visibility report too large");
|
||||
let views: Value = serde_json::from_slice(&bytes)?;
|
||||
if let Some(id) = views["stopRecording"].as_str() {
|
||||
self.casting.lock().unwrap().stop_recording_id(id);
|
||||
}
|
||||
if views["views"].is_object() {
|
||||
self.core("host.ui.visibility",views["views"].clone())?;
|
||||
if let Some(ids)=views["closedCasts"].as_array(){ensure!(ids.len()<=32,"Too many closed casts");for id in ids{if let Some(id)=id.as_str(){let _=self.receivers.lock().unwrap().control(id,"stop",json!({}));}}}
|
||||
@@ -395,7 +440,7 @@ impl Agent {
|
||||
.unwrap_or(json!({}));
|
||||
return send_json(
|
||||
r,
|
||||
json!({"feedback":database["feedback"],"commands":commands,"notificationBadge":badge,"gazeCalibration":self.core("launcher.gaze.calibration.get",json!({})).unwrap_or(Value::Null),"launcher":database["launcher"]}),
|
||||
json!({"feedback":database["feedback"],"commands":commands,"notificationBadge":badge,"gazeCalibration":self.core("launcher.gaze.calibration.get",json!({})).unwrap_or(Value::Null),"launcher":database["launcher"],"recording":self.casting.lock().unwrap().recording_status()}),
|
||||
);
|
||||
}
|
||||
if let Some(rel) = path.strip_prefix("/plugin-assets/") {
|
||||
@@ -511,6 +556,15 @@ impl Agent {
|
||||
}
|
||||
if let Some(rel) = path.strip_prefix("/assets/") {
|
||||
safe_path(rel)?;
|
||||
if guest {
|
||||
let mime = if path.ends_with(".js") { "text/javascript" } else { "text/css" };
|
||||
r.respond(Response::from_data(fs::read(self.assets.join(&path[1..]))?)
|
||||
.with_header(header("Content-Type", mime))
|
||||
.with_header(header("X-Content-Type-Options", "nosniff"))
|
||||
.with_header(header("Cache-Control", "no-store"))
|
||||
.with_header(header("Referrer-Policy", "no-referrer")))?;
|
||||
return Ok(());
|
||||
}
|
||||
return send_file(r, &self.assets.join(&path[1..]), true);
|
||||
}
|
||||
if let Some(key) = path.strip_prefix("/boot/") {
|
||||
@@ -538,7 +592,7 @@ impl Agent {
|
||||
}
|
||||
ensure!(
|
||||
if remote {
|
||||
self.network_authorized(&r)
|
||||
guest || self.network_authorized(&r)
|
||||
} else {
|
||||
self.authorized(&r)
|
||||
},
|
||||
@@ -791,11 +845,20 @@ impl Agent {
|
||||
);
|
||||
}
|
||||
if path == "/cast/whep" || path.starts_with("/cast/whep/") {
|
||||
// Serialize guest creation/registration with revocation: disabling
|
||||
// cannot miss a WHEP session that is still being created upstream.
|
||||
let mut grant = if guest { Some(self.watch_access.lock().unwrap()) } else { None };
|
||||
if let Some(access) = grant.as_ref() {
|
||||
if !watch_cookie.as_ref().is_some_and(|token| access.accepts(token)) { return Self::watch_expired(r); }
|
||||
}
|
||||
ensure!(matches!(r.method(), Method::Post | Method::Patch | Method::Delete | Method::Options), "Invalid WHEP method");
|
||||
let origin = r.headers().iter().find(|h| h.field.equiv("Origin")).map(|h|h.value.as_str());
|
||||
ensure!(origin == Some(expected_origin.as_str()), "Cross-origin playback request denied");
|
||||
if r.method()==&Method::Options {r.respond(Response::empty(StatusCode(204)).with_header(header("Accept-Post","application/sdp")))?;return Ok(());}
|
||||
let suffix = path.strip_prefix("/cast/whep").unwrap();
|
||||
if let Some(access) = grant.as_ref() {
|
||||
if !suffix.is_empty() && !access.sessions.contains_key(&path) { return Self::watch_expired(r); }
|
||||
}
|
||||
let mut body = Vec::new();
|
||||
r.as_reader().take(65537).read_to_end(&mut body)?;
|
||||
ensure!(body.len() <= 65536, "SDP too large");
|
||||
@@ -819,7 +882,7 @@ impl Agent {
|
||||
}
|
||||
let response = match request.send_bytes(&body) { Ok(v) => v, Err(ureq::Error::Status(_,v)) => v, Err(e) => return Err(e.into()) };
|
||||
let code = response.status();
|
||||
let mut headers = vec![header("Cache-Control", "no-store")];
|
||||
let mut headers = vec![header("Cache-Control", "no-store"), header("Referrer-Policy", "no-referrer")];
|
||||
for name in ["Content-Type", "ETag", "Accept-Patch"] {
|
||||
if let Some(value) = response.header(name) { headers.push(header(name,value)); }
|
||||
}
|
||||
@@ -828,6 +891,14 @@ impl Agent {
|
||||
let prefix=format!("/{media_path}/whep/");
|
||||
ensure!(parsed.path().starts_with(&prefix), "Invalid media session location");
|
||||
headers.push(header("Location", &format!("/cast/whep/{codec}/{}",parsed.path().strip_prefix(&prefix).unwrap())));
|
||||
if (200..300).contains(&code) {
|
||||
if let Some(access) = grant.as_mut() {
|
||||
access.sessions.insert(format!("/cast/whep/{codec}/{}", parsed.path().strip_prefix(&prefix).unwrap()), parsed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if r.method() == &Method::Delete && matches!(code, 200..=299 | 404 | 410) {
|
||||
if let Some(access) = grant.as_mut() { access.sessions.remove(&path); }
|
||||
}
|
||||
let mut bytes = Vec::new(); response.into_reader().take(65537).read_to_end(&mut bytes)?;
|
||||
ensure!(bytes.len() <= 65536, "Media response too large");
|
||||
@@ -889,7 +960,8 @@ impl Agent {
|
||||
} else {
|
||||
data
|
||||
};
|
||||
let response=Response::from_data(data).with_header(header("Content-Type","text/html; charset=utf-8")).with_header(header("Content-Security-Policy","default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; media-src 'self' blob:; frame-src 'self' http://localhost:*; connect-src 'self'; base-uri 'none'; form-action 'none'"));
|
||||
let data = if guest { String::from_utf8(data)?.replacen("<head>", "<head><meta name=\"framely-cast-guest\" content=\"true\">", 1).into_bytes() } else { data };
|
||||
let response=Response::from_data(data).with_header(header("Content-Type","text/html; charset=utf-8")).with_header(header("Cache-Control","no-store")).with_header(header("Referrer-Policy","no-referrer")).with_header(header("Content-Security-Policy","default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https: http:; media-src 'self' blob:; frame-src 'self' http://localhost:*; connect-src 'self'; base-uri 'none'; form-action 'none'"));
|
||||
r.respond(response)?;
|
||||
return Ok(());
|
||||
}
|
||||
@@ -898,6 +970,10 @@ impl Agent {
|
||||
}
|
||||
fn maintain_cast(&self) {
|
||||
self.casting.lock().unwrap().maintain();
|
||||
// Retry failed disconnects, without interrupting authenticated viewers.
|
||||
if let Ok(mut access) = self.watch_access.try_lock() {
|
||||
if !access.revoked.is_empty() { access.disconnect_revoked(); }
|
||||
}
|
||||
let events=self.receivers.lock().unwrap().poll();
|
||||
for event in events {
|
||||
let session=&event["session"];
|
||||
@@ -945,13 +1021,25 @@ impl Agent {
|
||||
match method {
|
||||
"cast.status" => {
|
||||
let mut value = self.casting.lock().unwrap().status();
|
||||
let access = self.watch_access.lock().unwrap();
|
||||
let watch_path = access.path();
|
||||
value["watchAccessEnabled"] = json!(access.token.is_some());
|
||||
value["watchPath"] = json!(watch_path);
|
||||
let receivers = self.receivers.lock().unwrap();
|
||||
value["sessions"] = json!(receivers.sessions.values().collect::<Vec<_>>());
|
||||
value["receiverError"] = json!(receivers.error);
|
||||
let network=self.network_status.lock().unwrap();
|
||||
value["watchAddresses"]=if network["enabled"]==true{json!(network_ipv4_addresses().into_iter().map(|ip|format!("http://{ip}:{}/cast/watch",network["port"])).collect::<Vec<_>>())}else{json!([])};
|
||||
value["watchAddresses"]=if network["enabled"]==true{json!(network_ipv4_addresses().into_iter().map(|ip|format!("http://{ip}:{}{watch_path}",network["port"])).collect::<Vec<_>>())}else{json!([])};
|
||||
Ok(value)
|
||||
},
|
||||
"cast.watchAccess.save" => {
|
||||
let mut access = self.watch_access.lock().unwrap();
|
||||
let result = self.core(method, p)?;
|
||||
access.replace(result["token"].as_str().map(str::to_owned));
|
||||
access.disconnect_revoked();
|
||||
Ok(json!({"enabled":access.token.is_some()}))
|
||||
},
|
||||
"cast.watchAccess.get" => anyhow::bail!("Internal watch access operation"),
|
||||
"cast.settings.save" => {
|
||||
let settings: crate::cast::settings::Settings = serde_json::from_value(p.clone())?;
|
||||
settings.validate()?;
|
||||
@@ -1013,6 +1101,23 @@ impl Agent {
|
||||
self.casting.lock().unwrap().start(&settings, panel.as_deref())
|
||||
}
|
||||
"cast.stop" => Ok(self.casting.lock().unwrap().stop()),
|
||||
"cast.record.start" => {
|
||||
let mut cast = self.casting.lock().unwrap();
|
||||
let auto_started = cast.status()["running"] != true;
|
||||
if auto_started {
|
||||
let status = self.core("status", json!({}))?;
|
||||
let settings: crate::cast::settings::Settings = serde_json::from_value(status["database"]["casting"].clone())?;
|
||||
let panel = if settings.source == crate::cast::settings::Source::Screen {
|
||||
let response = self.core("cast.panel.start", json!({}))?;
|
||||
Some(PathBuf::from(response["socket"].as_str().context("Missing panel socket")?))
|
||||
} else {None};
|
||||
cast.start(&settings, panel.as_deref())?;
|
||||
}
|
||||
let result = cast.start_recording(&crate::steam::home()?.join("Videos/framely"));
|
||||
if result.is_err() && auto_started {cast.stop();}
|
||||
result
|
||||
},
|
||||
"cast.record.stop" => self.casting.lock().unwrap().stop_recording(),
|
||||
"launcher.search.index" => {
|
||||
let names: Vec<String> = serde_json::from_value(p["names"].clone())?;
|
||||
ensure!(
|
||||
@@ -1898,6 +2003,10 @@ pub fn serve(
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&runtime, fs::Permissions::from_mode(0o700))?;
|
||||
let agent = Arc::new(Agent {
|
||||
watch_access: Mutex::new(crate::cast::watch_access::Access {
|
||||
token: ipc::call(&socket, "cast.watchAccess.get", json!({}))?["token"].as_str().map(str::to_owned),
|
||||
..Default::default()
|
||||
}),
|
||||
casting: Mutex::new(crate::cast::runtime::Casting::new(runtime.join("cast"), crate::cast::runtime::binary_directory(&assets))),
|
||||
receivers: Mutex::new(crate::cast::receiver::Receivers::new(runtime.join("receive"), crate::cast::runtime::binary_directory(&assets))),
|
||||
socket,
|
||||
@@ -2431,6 +2540,7 @@ mod tests {
|
||||
|
||||
fn agent(server: &Server, root: &Path) -> Arc<Agent> {
|
||||
Arc::new(Agent {
|
||||
watch_access: Mutex::default(),
|
||||
casting: Mutex::new(crate::cast::runtime::Casting::new(root.join("cast"), root.join("media"))),
|
||||
receivers: Mutex::new(crate::cast::receiver::Receivers::new(root.join("receive"), root.join("media"))),
|
||||
socket: root.join("control.sock"),
|
||||
@@ -2880,6 +2990,126 @@ mod tests {
|
||||
thread.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watch_access_limits_http_routes_and_revokes_only_guest_whep() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let server = Server::http("127.0.0.1:0").unwrap();
|
||||
let agent = agent(&server, root.path());
|
||||
let mut core = crate::tests::accepted_service(&agent.state, unsafe { libc::geteuid() }).unwrap();
|
||||
// Authentication is exercised through existing manager cookies, not a
|
||||
// password hash, so no password derivation is needed in this fixture.
|
||||
fs::write(agent.state.join("network-password.json"), b"{}").unwrap();
|
||||
core.db.network_panel.password_enabled = true;
|
||||
let socket = UnixListener::bind(&agent.socket).unwrap();
|
||||
socket.set_nonblocking(true).unwrap();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let core_stop = stop.clone();
|
||||
let core_worker = std::thread::spawn(move || {
|
||||
while !core_stop.load(Ordering::Relaxed) {
|
||||
if let Ok((mut stream,_)) = socket.accept() {
|
||||
let request = ipc::read(&mut stream).unwrap();
|
||||
let response = ipc::response(core.handle(request["method"].as_str().unwrap(), request["params"].clone()));
|
||||
ipc::write(&mut stream, &response).unwrap();
|
||||
} else { std::thread::sleep(Duration::from_millis(5)); }
|
||||
}
|
||||
});
|
||||
let worker_agent = agent.clone();
|
||||
let worker_stop = stop.clone();
|
||||
let worker = std::thread::spawn(move || {
|
||||
while !worker_stop.load(Ordering::Relaxed) {
|
||||
if let Ok(Some(r)) = server.recv_timeout(Duration::from_millis(50)) {
|
||||
let _ = worker_agent.handle_http(r, true);
|
||||
}
|
||||
}
|
||||
});
|
||||
let media = Server::http("127.0.0.1:0").unwrap();
|
||||
agent.casting.lock().unwrap().test_whep_server(media.server_addr().to_ip().unwrap().port());
|
||||
let media_stop = stop.clone();
|
||||
let deleted = Arc::new(Mutex::new(Vec::new()));
|
||||
let media_deleted = deleted.clone();
|
||||
let (creating_tx, creating_rx) = std::sync::mpsc::channel();
|
||||
let (finish_tx, finish_rx) = std::sync::mpsc::channel();
|
||||
let media_worker = std::thread::spawn(move || {
|
||||
let mut number = 0;
|
||||
while !media_stop.load(Ordering::Relaxed) {
|
||||
if let Ok(Some(r)) = media.recv_timeout(Duration::from_millis(50)) {
|
||||
if r.method() == &Method::Post {
|
||||
number += 1;
|
||||
if number == 3 {
|
||||
creating_tx.send(()).unwrap();
|
||||
finish_rx.recv_timeout(Duration::from_secs(3)).unwrap();
|
||||
}
|
||||
r.respond(Response::from_string("answer").with_status_code(201)
|
||||
.with_header(header("Location", &format!("/headset/whep/00000000-0000-0000-0000-{number:012}")))).unwrap();
|
||||
} else if r.method() == &Method::Delete {
|
||||
media_deleted.lock().unwrap().push(r.url().to_owned());
|
||||
r.respond(Response::empty(StatusCode(204))).unwrap();
|
||||
} else { r.respond(Response::empty(StatusCode(400))).unwrap(); }
|
||||
}
|
||||
}
|
||||
});
|
||||
agent.api(json!({"method":"cast.watchAccess.save","params":{"enabled":true}})).unwrap();
|
||||
let token = agent.watch_access.lock().unwrap().token.clone().unwrap();
|
||||
let client = ureq::AgentBuilder::new().redirects(0).timeout(Duration::from_secs(3)).build();
|
||||
let origin = &agent.origin;
|
||||
let grant = client.get(&format!("{origin}/cast/watch?token={token}")).call().unwrap();
|
||||
assert_eq!(grant.status(), 303);
|
||||
assert_eq!(grant.header("Location"), Some("/cast/watch"));
|
||||
assert_eq!(grant.header("Referrer-Policy"), Some("no-referrer"));
|
||||
let cookie_header = grant.header("Set-Cookie").unwrap();
|
||||
assert!(cookie_header.contains("HttpOnly") && cookie_header.contains("SameSite=Strict"));
|
||||
let cookie = cookie_header.split(';').next().unwrap().to_owned();
|
||||
fs::remove_file(agent.state.join("network-password.json")).unwrap();
|
||||
let watch = client.get(&format!("{origin}/cast/watch")).set("Cookie", &cookie).call().unwrap();
|
||||
assert_eq!(watch.header("Cache-Control"), Some("no-store"));
|
||||
assert!(watch.into_string().unwrap().contains("framely-cast-guest"));
|
||||
for asset in ["app.js", "app.css"] {
|
||||
assert!(client.get(&format!("{origin}/assets/{asset}")).set("Cookie", &cookie).call().is_ok());
|
||||
}
|
||||
fs::write(agent.state.join("network-password.json"), b"{}").unwrap();
|
||||
let manager = client.get(&format!("{origin}/manager")).set("Cookie", &cookie).call().unwrap().into_string().unwrap();
|
||||
assert!(manager.contains("action=\"/login\""));
|
||||
for path in ["/api", "/manager-api/files", "/api/upload/a/0", "/host/poll"] {
|
||||
assert!(client.post(&format!("{origin}{path}")).set("Cookie", &cookie).set("Origin", origin).send_json(json!({"method":"cast.watchAccess.save","params":{"enabled":false}})).is_err());
|
||||
}
|
||||
assert!(client.get(&format!("{origin}/cast/watch?token=invalid")).call().is_err());
|
||||
assert!(client.post(&format!("{origin}/cast/whep")).set("Cookie", &cookie).set("Origin", "http://evil.test").send_string("a=rtpmap:96 H264/90000").is_err());
|
||||
let guest = client.post(&format!("{origin}/cast/whep")).set("Cookie", &cookie).set("Origin", origin).send_string("a=rtpmap:96 H264/90000").unwrap();
|
||||
assert_eq!(guest.status(), 201);
|
||||
let guest_resource = guest.header("Location").unwrap().to_owned();
|
||||
let manager_cookie = format!("framely-network={}", agent.network_key.lock().unwrap());
|
||||
let logged_in = client.post(&format!("{origin}/cast/whep")).set("Cookie", &manager_cookie).set("Origin", origin).send_string("a=rtpmap:96 H264/90000").unwrap();
|
||||
assert_eq!(logged_in.status(), 201);
|
||||
assert_eq!(agent.watch_access.lock().unwrap().sessions.len(), 1);
|
||||
agent.api(json!({"method":"cast.watchAccess.save","params":{"enabled":false}})).unwrap();
|
||||
assert_eq!(deleted.lock().unwrap().as_slice(), &["/headset/whep/00000000-0000-0000-0000-000000000001"]);
|
||||
assert!(client.get(&format!("{origin}/cast/access")).set("Cookie", &cookie).call().is_err());
|
||||
assert!(client.get(&format!("{origin}/cast/watch?token={token}")).call().is_err());
|
||||
assert!(client.delete(&format!("{origin}{guest_resource}")).set("Cookie", &cookie).set("Origin", origin).call().is_err());
|
||||
assert_eq!(client.get(&format!("{origin}/cast/access")).set("Cookie", &manager_cookie).call().unwrap().status(), 204);
|
||||
// Revoke while the upstream WHEP POST is in flight. Disabling must
|
||||
// wait for registration and then delete that new session as well.
|
||||
agent.api(json!({"method":"cast.watchAccess.save","params":{"enabled":true}})).unwrap();
|
||||
let new_token = agent.watch_access.lock().unwrap().token.clone().unwrap();
|
||||
assert_ne!(new_token, token);
|
||||
let race_origin = origin.clone();
|
||||
let creating = std::thread::spawn(move || {
|
||||
ureq::post(&format!("{race_origin}/cast/whep")).set("Cookie", &format!("framely-watch={new_token}"))
|
||||
.set("Origin", &race_origin).send_string("a=rtpmap:96 H264/90000").unwrap().status()
|
||||
});
|
||||
creating_rx.recv_timeout(Duration::from_secs(3)).unwrap();
|
||||
assert!(agent.watch_access.try_lock().is_err());
|
||||
let disabling_agent = agent.clone();
|
||||
let disabling = std::thread::spawn(move || disabling_agent.api(json!({"method":"cast.watchAccess.save","params":{"enabled":false}})).unwrap());
|
||||
finish_tx.send(()).unwrap();
|
||||
assert_eq!(creating.join().unwrap(), 201);
|
||||
disabling.join().unwrap();
|
||||
assert_eq!(deleted.lock().unwrap().len(), 2);
|
||||
assert!(deleted.lock().unwrap()[1].ends_with("000000000003"));
|
||||
stop.store(true, Ordering::Relaxed);
|
||||
worker.join().unwrap(); core_worker.join().unwrap(); media_worker.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_panel_requires_login_and_same_origin_and_blocks_native_routes() {
|
||||
let password = "密码".repeat(70);
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import {build} from 'esbuild';
|
||||
import assert from 'node:assert/strict';
|
||||
import {createRequire} from 'node:module';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
Object.defineProperty(globalThis,'navigator',{value:{languages:['zh-CN']},configurable:true});
|
||||
globalThis.document={documentElement:{lang:''}};
|
||||
const bundle=await build({stdin:{contents:`import React from 'react';import {renderToStaticMarkup} from 'react-dom/server';import {RecordingButton} from './ui/src/cast-recording';export const render=(recording,disabled=false)=>renderToStaticMarkup(<RecordingButton recording={recording} disabled={disabled} onClick={()=>{}}/>);`,resolveDir:process.cwd(),loader:'tsx'},bundle:true,platform:'node',format:'cjs',write:false});
|
||||
const module={exports:{}};new Function('module','exports','require',bundle.outputFiles[0].text)(module,module.exports,createRequire(import.meta.url));
|
||||
const idle=module.exports.render({active:false});assert.ok(idle.includes('开始录制'));assert.ok(!idle.includes('停止录制'));
|
||||
const active=module.exports.render({active:true,elapsedSeconds:125,path:'/home/steamos/Videos/framely/test.mkv'});
|
||||
assert.ok(active.includes('停止录制'));assert.ok(active.includes('02:05'));assert.ok(active.includes('test.mkv'));assert.ok(active.includes(' recording'));
|
||||
assert.ok(module.exports.render({active:true},true).includes('disabled=""'));
|
||||
for(const locale of ['en-US','zh-CN']){
|
||||
const {messages}=JSON.parse(await readFile(`ui/locales/${locale}.json`,'utf8'));
|
||||
for(const key of ['开始录制','停止录制','录制保存到 /home/steamos/Videos/framely'])assert.ok(messages[key]);
|
||||
}
|
||||
console.log('PASS: recording button idle/active/disabled states, timer, path and translations');
|
||||
@@ -0,0 +1,23 @@
|
||||
#include "openvr.h"
|
||||
#include "recording_indicator.h"
|
||||
#include "launcher_input.h"
|
||||
#include <cassert>
|
||||
int main(){
|
||||
RecordingIndicatorClick click;
|
||||
assert(!click.update(true,true));assert(!click.update(false,true));
|
||||
assert(!click.update(true,false));assert(!click.update(false,true));
|
||||
assert(!click.update(true,true));assert(click.update(false,true));assert(!click.update(false,true));
|
||||
assert(!click.update(true,true));assert(!click.update(false,true,false));assert(!click.update(false,true));
|
||||
EntryAnalogTrigger trigger;RecordingIndicatorClick analog_click;
|
||||
assert(!analog_click.update(trigger.update(0.f),true));
|
||||
assert(!analog_click.update(trigger.update(.6f),true));
|
||||
assert(!analog_click.update(trigger.update(.46f),true));
|
||||
assert(analog_click.update(trigger.update(.44f),true));
|
||||
for(auto seconds:{0ULL,59ULL,60ULL,3600ULL,9999999ULL}){
|
||||
auto normal=recording_indicator_pixels(seconds,false),hover=recording_indicator_pixels(seconds,true);
|
||||
assert(normal.size()==320*80*4&&hover.size()==normal.size());
|
||||
assert(normal!=hover);assert(normal[3]==0);
|
||||
auto red=(40*320+32)*4;assert(normal[red]>200&&normal[red+1]<100);
|
||||
auto stop=(40*320+280)*4;assert(normal[stop]==245&&normal[stop+3]==255);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ cargo build --release --locked --manifest-path media/capture/Cargo.toml
|
||||
mkdir -p media/bin
|
||||
capture_target=$(cargo metadata --locked --no-deps --format-version 1 --manifest-path media/capture/Cargo.toml | python3 -c 'import json,sys; print(json.load(sys.stdin)["target_directory"])')
|
||||
cp "$capture_target/release/framely-capture" "$capture_target/release/framely-panel-grab" media/bin/
|
||||
# Incremental release builds may reuse unchanged AirPlay/MediaMTX artifacts.
|
||||
[[ ${FRAMELY_CAPTURE_ONLY:-0} != 1 ]] || exit 0
|
||||
# Native dependencies use the target system's GStreamer and GUPnP libraries.
|
||||
python3 tools/fetch-media.py
|
||||
bash tools/build-gst-libav.sh
|
||||
|
||||
@@ -10,7 +10,12 @@ npm ci --no-audit --no-fund
|
||||
npm run build
|
||||
bash tools/build-native.sh "$cef"
|
||||
if [[ ${FRAMELY_USE_PREBUILT_MEDIA:-0} == 1 ]]; then
|
||||
[[ -z $(git status --porcelain -- media) ]] || { echo 'Media sources changed; rebuild media.' >&2; exit 1; }
|
||||
if [[ ${FRAMELY_REBUILD_CAPTURE_ONLY:-0} == 1 ]]; then
|
||||
[[ -z $(git status --porcelain -- media ':!media/capture') ]] || { echo 'Non-capture media sources changed; rebuild all media.' >&2; exit 1; }
|
||||
FRAMELY_CAPTURE_ONLY=1 bash tools/build-media.sh
|
||||
else
|
||||
[[ -z $(git status --porcelain -- media) ]] || { echo 'Media sources changed; rebuild media.' >&2; exit 1; }
|
||||
fi
|
||||
for file in media/bin/framely-panel-grab media/bin/framely-capture media/bin/mediamtx media/bin/uxplay media/bin/framely-receiver media/bin/libgstframely.so media/bin/libgstlibav.so media/bin/LICENSE media/bin/LICENSE.uxplay media/bin/LICENSE.gst-libav media/bin/LICENSE.llhttp; do
|
||||
[[ -s $file ]] || { echo "Missing prebuilt media: $file" >&2; exit 1; }
|
||||
done
|
||||
|
||||
@@ -3,7 +3,7 @@ set -euo pipefail
|
||||
base=$(cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
cd "$base"
|
||||
mkdir -p target/tests
|
||||
for test in gamepad_mapping gamepad_steam gamepad_direct entry_hold menu_geometry keyboard_state notification_geometry notification_badge ui_visibility plugin_navigation launcher_input launcher_gaze gaze_geometry launcher_fade launcher_pointer page_scroll paint_buffer render_timing launcher_priority; do
|
||||
for test in gamepad_mapping gamepad_steam gamepad_direct entry_hold menu_geometry keyboard_state notification_geometry notification_badge ui_visibility plugin_navigation launcher_input launcher_gaze gaze_geometry launcher_fade launcher_pointer page_scroll paint_buffer render_timing launcher_priority recording_indicator; do
|
||||
"${CXX:-g++}" -std=c++17 -O2 -pthread -Inative -Inative/vendor/openvr "tests/$test.cpp" -ldl -o "target/tests/$test"
|
||||
"target/tests/$test"
|
||||
done
|
||||
|
||||
@@ -3,6 +3,20 @@
|
||||
"locale": "en-US",
|
||||
"name": "English",
|
||||
"messages": {
|
||||
"安卓权限请求": "Android permission request",
|
||||
"正在读取权限请求": "Reading permission request",
|
||||
"安卓权限选择": "Android permission selection",
|
||||
"安卓原生权限对话框": "Native Android permission dialog",
|
||||
"正在等待安卓权限画面…": "Waiting for the Android permission screen…",
|
||||
"使用手柄激光选择原生权限选项": "Use the controller laser to select a native permission option",
|
||||
"开始录制": "Start recording",
|
||||
"停止录制": "Stop recording",
|
||||
"录制保存到 /home/steamos/Videos/framely": "Recordings are saved to /home/steamos/Videos/framely",
|
||||
"免验证": "Password-free viewing",
|
||||
"持有此链接的人无需密码即可观看。": "Anyone with this link can watch without a password.",
|
||||
"观看链接已失效": "This viewing link has expired",
|
||||
"正在读取串流信息…": "Reading stream information…",
|
||||
"当前浏览器实际接收并解码的串流信息": "Actual stream received and decoded by this browser",
|
||||
"L2 / R2 按键触发行程": "L2 / R2 button activation threshold",
|
||||
"默认 80%。达到此行程才触发按键,模拟轴保持完整行程;保存后立即生效。": "Default: 80%. Buttons activate at this trigger travel. Analog axes retain their full range; saving applies immediately.",
|
||||
"更新应用并保留现有存档和设置。": "Update the app while keeping your saves and settings.",
|
||||
|
||||
@@ -3,6 +3,20 @@
|
||||
"locale": "zh-CN",
|
||||
"name": "简体中文",
|
||||
"messages": {
|
||||
"安卓权限请求": "安卓权限请求",
|
||||
"正在读取权限请求": "正在读取权限请求",
|
||||
"安卓权限选择": "安卓权限选择",
|
||||
"安卓原生权限对话框": "安卓原生权限对话框",
|
||||
"正在等待安卓权限画面…": "正在等待安卓权限画面…",
|
||||
"使用手柄激光选择原生权限选项": "使用手柄激光选择原生权限选项",
|
||||
"开始录制": "开始录制",
|
||||
"停止录制": "停止录制",
|
||||
"录制保存到 /home/steamos/Videos/framely": "录制保存到 /home/steamos/Videos/framely",
|
||||
"免验证": "免验证",
|
||||
"持有此链接的人无需密码即可观看。": "持有此链接的人无需密码即可观看。",
|
||||
"观看链接已失效": "观看链接已失效",
|
||||
"正在读取串流信息…": "正在读取串流信息…",
|
||||
"当前浏览器实际接收并解码的串流信息": "当前浏览器实际接收并解码的串流信息",
|
||||
"L2 / R2 按键触发行程": "L2 / R2 按键触发行程",
|
||||
"默认 80%。达到此行程才触发按键,模拟轴保持完整行程;保存后立即生效。": "默认 80%。达到此行程才触发按键,模拟轴保持完整行程;保存后立即生效。",
|
||||
"安装前需要关闭容器,以安全备份应用数据。关闭会中断其中正在运行的应用,保留存档和设置。": "安装前需要关闭容器,以安全备份应用数据。关闭会中断其中正在运行的应用,保留存档和设置。",
|
||||
|
||||
@@ -4,8 +4,9 @@ import {api} from './api';
|
||||
import {t} from './i18n';
|
||||
import {Switch} from './switch';
|
||||
import type {CastSettings} from './casting';
|
||||
import {RecordingButton,type RecordingStatus} from './cast-recording';
|
||||
|
||||
type Runtime={running:boolean;error?:string;receiverError?:string;watchAddresses?:string[]};
|
||||
type Runtime={running:boolean;recording?:RecordingStatus;preview?:boolean;error?:string;receiverError?:string;watchAddresses?:string[]};
|
||||
export function CastQuick({config,refresh}:{config:CastSettings;refresh:()=>Promise<void>}){
|
||||
const [runtime,setRuntime]=useState<Runtime|null>(null),[busy,setBusy]=useState(false),[error,setError]=useState('');
|
||||
useEffect(()=>{let alive=true;const poll=()=>void api<Runtime>('cast.status').then(v=>{if(alive)setRuntime(v);}).catch(e=>{if(alive)setError(e.message);});poll();const timer=setInterval(poll,1000);return()=>{alive=false;clearInterval(timer);};},[]);
|
||||
@@ -13,7 +14,8 @@ export function CastQuick({config,refresh}:{config:CastSettings;refresh:()=>Prom
|
||||
const running=!!runtime?.running;
|
||||
return <section className="cast-quick">
|
||||
<header><IconCast size={25} stroke={1.8}/><h1>{t('串流')}</h1><button aria-label={t('投屏设置')} onClick={()=>void run(()=>api('host.manager.open',{page:'casting'}))}><IconArrowUpRight size={22}/></button></header>
|
||||
<div className="cast-quick-stream"><IconDeviceVisionPro size={40} stroke={1.5}/><h2>{running?t('正在串流'):t('头显画面')}</h2><p>{runtime?`${config.width} × ${config.height} / ${config.fps} FPS`:t('正在连接')}</p><button className={running?'':'primary'} disabled={busy||!runtime} onClick={()=>void run(()=>api(running?'cast.stop':'cast.start'))}>{running?<IconPlayerStop size={21}/>:<IconPlayerPlay size={21}/>}<span>{running?t('停止串流'):t('开始串流')}</span></button></div>
|
||||
<div className="cast-quick-stream"><IconDeviceVisionPro size={40} stroke={1.5}/><h2>{running?t('正在串流'):t('头显画面')}</h2><p>{runtime?`${config.width} × ${config.height} / ${config.fps} FPS`:t('正在连接')}</p><div className="cast-stream-actions"><button className={running?'':'primary'} disabled={busy||!runtime} onClick={()=>void run(()=>api(running?'cast.stop':'cast.start'))}>{running?<IconPlayerStop size={21}/>:<IconPlayerPlay size={21}/>}<span>{running?t('停止串流'):t('开始串流')}</span></button><RecordingButton recording={runtime?.recording} disabled={busy||!runtime||!!runtime.preview} onClick={()=>void run(()=>api(runtime?.recording?.active?'cast.record.stop':'cast.record.start'))}/></div><p className="cast-record-path">{t('录制保存到 /home/steamos/Videos/framely')}</p></div>
|
||||
{runtime?.recording?.error&&<p className="error" role="alert">{runtime.recording.error}</p>}
|
||||
{running&&runtime?.watchAddresses?.[0]&&<div className="cast-quick-address"><span>{t('网页观看')}</span><code>{runtime.watchAddresses[0]}</code></div>}
|
||||
<div className="cast-quick-receive"><h2>{t('接收投屏')}</h2>{(['airplay','dlna'] as const).map(protocol=><label key={protocol}><span>{protocol==='airplay'?'AirPlay':'DLNA'}</span><Switch label={protocol==='airplay'?'AirPlay':'DLNA'} checked={config[protocol]} disabled={busy} onChange={value=>void run(()=>api('cast.settings.save',{...config,[protocol]:value}))}/></label>)}</div>
|
||||
{(error||runtime?.error||runtime?.receiverError)&&<p className="error" role="alert">{error||runtime?.error||runtime?.receiverError}</p>}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import {IconCircleFilled,IconPlayerStop} from '@tabler/icons-react';
|
||||
import {t} from './i18n';
|
||||
export type RecordingStatus={active:boolean;elapsedSeconds?:number;path?:string|null;error?:string|null};
|
||||
export function RecordingButton({recording,disabled,onClick}:{recording?:RecordingStatus;disabled:boolean;onClick:()=>void}){
|
||||
const seconds=recording?.elapsedSeconds??0;
|
||||
const elapsed=`${Math.floor(seconds/60).toString().padStart(2,'0')}:${(seconds%60).toString().padStart(2,'0')}`;
|
||||
return <button className={`cast-record-button${recording?.active?' recording':''}`} disabled={disabled} onClick={onClick} title={recording?.path??'/home/steamos/Videos/framely'}>{recording?.active?<IconPlayerStop size={21}/>:<IconCircleFilled size={18}/>}<span>{t(recording?.active?'停止录制':'开始录制')}{recording?.active&&` · ${elapsed}`}</span></button>;
|
||||
}
|
||||
+46
-5
@@ -3,21 +3,60 @@ import {IconArrowLeft,IconPlayerPause,IconPlayerPlay,IconVolume,IconVolumeOff,Ic
|
||||
import {CastRange} from './cast-range';
|
||||
import {t} from './i18n';
|
||||
|
||||
export function CastVideo({active,onError,controls=true,aspectRatio,videoRef,onState,showStatus=true}:{active:boolean;onError?:(error:string)=>void;controls?:boolean;aspectRatio?:number;videoRef?:React.RefObject<HTMLVideoElement|null>;onState?:(state:'connecting'|'playing'|'failed')=>void;showStatus?:boolean}){
|
||||
export type CastStreamInfo={width:number|null;height:number|null;codec:string|null;fps:number|null};
|
||||
export function CastVideo({active,onError,controls=true,aspectRatio,videoRef,onState,onStreamInfo,showStatus=true}:{active:boolean;onError?:(error:string)=>void;controls?:boolean;aspectRatio?:number;videoRef?:React.RefObject<HTMLVideoElement|null>;onState?:(state:'connecting'|'playing'|'failed')=>void;onStreamInfo?:(info:CastStreamInfo|null)=>void;showStatus?:boolean}){
|
||||
const localVideo=useRef<HTMLVideoElement>(null),video=videoRef??localVideo,failure=useRef(onError);failure.current=onError;
|
||||
const streamInfo=useRef(onStreamInfo);streamInfo.current=onStreamInfo;
|
||||
const [state,setState]=useState<'connecting'|'playing'|'failed'>('connecting');
|
||||
useEffect(()=>{onState?.(state);},[state,onState]);
|
||||
const [ratio,setRatio]=useState(16/9),[retry,setRetry]=useState(0);
|
||||
useEffect(()=>{
|
||||
if(!active)return;
|
||||
const peer=new RTCPeerConnection(),abort=new AbortController();let resource:string|undefined,closed=false;
|
||||
let previous:{id:string;frames:number;timestamp:number}|undefined,statsBusy=false;
|
||||
streamInfo.current?.(null);
|
||||
async function reportStreamInfo(){
|
||||
if(closed||statsBusy||!streamInfo.current)return;
|
||||
if(peer.connectionState!=='connected'){previous=undefined;streamInfo.current?.(null);return;}
|
||||
statsBusy=true;
|
||||
try{
|
||||
const stats=await peer.getStats();if(closed)return;
|
||||
let inbound:RTCInboundRtpStreamStats|undefined;
|
||||
stats.forEach(report=>{if(report.type==='inbound-rtp'&&(report.kind==='video'||report.mediaType==='video'))inbound=report;});
|
||||
if(!inbound){previous=undefined;streamInfo.current?.(null);return;}
|
||||
const {id,framesDecoded,timestamp,frameWidth,frameHeight,codecId}=inbound;
|
||||
let fps:number|null=null;
|
||||
if(typeof framesDecoded==='number'){
|
||||
if(previous?.id===id&&framesDecoded>=previous.frames&×tamp>previous.timestamp)fps=(framesDecoded-previous.frames)*1000/(timestamp-previous.timestamp);
|
||||
previous={id,frames:framesDecoded,timestamp};
|
||||
}
|
||||
let mime:string|undefined;
|
||||
stats.forEach(report=>{if(report.id===codecId&&report.type==='codec')mime=report.mimeType;});
|
||||
const name=typeof mime==='string'?mime.replace(/^video\//i,''):null;
|
||||
const codec=name?/^(H265|HEVC)$/i.test(name)?'H.265':/^H264$/i.test(name)?'H.264':name.toUpperCase():null;
|
||||
streamInfo.current?.({width:typeof frameWidth==='number'&&frameWidth>0?frameWidth:video.current?.videoWidth||null,height:typeof frameHeight==='number'&&frameHeight>0?frameHeight:video.current?.videoHeight||null,codec,fps});
|
||||
}catch{if(!closed){previous=undefined;streamInfo.current?.(null);}}
|
||||
finally{statsBusy=false;}
|
||||
}
|
||||
const statsTimer=setInterval(()=>void reportStreamInfo(),1000);
|
||||
const guest=!!document.querySelector('meta[name="framely-cast-guest"]');
|
||||
let accessBusy=false;
|
||||
const accessTimer=guest?setInterval(()=>{
|
||||
if(closed||accessBusy)return;accessBusy=true;
|
||||
void fetch('/cast/access',{cache:'no-store',signal:abort.signal}).then(response=>{
|
||||
if(!closed&&response.headers.get('X-Framely-Watch-Expired')==='1'){
|
||||
closed=true;abort.abort();peer.close();if(video.current)video.current.srcObject=null;
|
||||
setState('failed');failure.current?.(t('观看链接已失效'));
|
||||
}
|
||||
}).catch(()=>{}).finally(()=>{accessBusy=false;});
|
||||
},2000):undefined;
|
||||
setState('connecting');
|
||||
for(const kind of ['video','audio']){
|
||||
const receiver=peer.addTransceiver(kind,{direction:'recvonly'}).receiver;
|
||||
if('jitterBufferTarget' in receiver){try{(receiver as RTCRtpReceiver&{jitterBufferTarget:number|null}).jitterBufferTarget=0;}catch{}}
|
||||
}
|
||||
peer.ontrack=event=>{if(video.current){const stream=video.current.srcObject as MediaStream|null??new MediaStream();stream.addTrack(event.track);video.current.srcObject=stream;void video.current.play().catch(()=>{});}};
|
||||
peer.onconnectionstatechange=()=>{if(peer.connectionState==='failed'){setState('failed');failure.current?.(t('观看连接失败,请重新连接。'));}};
|
||||
peer.onconnectionstatechange=()=>{if(peer.connectionState==='failed'){previous=undefined;streamInfo.current?.(null);setState('failed');failure.current?.(t('观看连接失败,请重新连接。'));}};
|
||||
void(async()=>{
|
||||
try{
|
||||
await peer.setLocalDescription(await peer.createOffer());
|
||||
@@ -28,13 +67,14 @@ export function CastVideo({active,onError,controls=true,aspectRatio,videoRef,onS
|
||||
peer.addEventListener('icegatheringstatechange',check);abort.signal.addEventListener('abort',cancel,{once:true});check();
|
||||
});
|
||||
const response=await fetch('/cast/whep',{method:'POST',headers:{'Content-Type':'application/sdp'},body:peer.localDescription?.sdp,signal:abort.signal});
|
||||
if(response.headers.get('X-Framely-Watch-Expired')==='1')throw Error(t('观看链接已失效'));
|
||||
if(!response.ok)throw Error(await response.text()||t('串流尚未就绪,请稍后重试。'));
|
||||
resource=response.headers.get('Location')??undefined;
|
||||
if(closed){if(resource)void fetch(resource,{method:'DELETE'});return;}
|
||||
await peer.setRemoteDescription({type:'answer',sdp:await response.text()});
|
||||
}catch(error){if(!abort.signal.aborted){setState('failed');failure.current?.(String(error));}}
|
||||
})();
|
||||
return()=>{closed=true;abort.abort();peer.close();if(resource)void fetch(resource,{method:'DELETE'}).catch(()=>{});if(video.current)video.current.srcObject=null;};
|
||||
return()=>{closed=true;clearInterval(statsTimer);clearInterval(accessTimer);streamInfo.current?.(null);abort.abort();peer.close();if(resource)void fetch(resource,{method:'DELETE'}).catch(()=>{});if(video.current)video.current.srcObject=null;};
|
||||
},[active,retry]);
|
||||
return <div className="cast-video-stage" style={{aspectRatio:aspectRatio??ratio}}><video ref={video} autoPlay playsInline muted controls={controls} onLoadedMetadata={()=>{if(video.current?.videoHeight)setRatio(video.current.videoWidth/video.current.videoHeight);}} onPlaying={()=>setState('playing')}/>{showStatus&&active&&state==='connecting'&&<span role="status">{t('正在连接画面…')}</span>}{showStatus&&active&&state==='failed'&&<div className="cast-video-failure"><p>{t('观看连接失败,请重新连接。')}</p><button onClick={()=>setRetry(v=>v+1)}>{t('重新连接')}</button></div>}{!active&&<span>{t('开始串流后显示头显画面。')}</span>}</div>;
|
||||
}
|
||||
@@ -46,13 +86,14 @@ export function CastViewer(){
|
||||
useEffect(()=>{const changed=()=>setFullscreen(!!document.fullscreenElement);document.addEventListener('fullscreenchange',changed);return()=>document.removeEventListener('fullscreenchange',changed);},[]);
|
||||
useEffect(()=>{const el=video.current;if(!el)return;const play=()=>setPaused(false),pause=()=>setPaused(true);el.addEventListener('play',play);el.addEventListener('pause',pause);return()=>{el.removeEventListener('play',play);el.removeEventListener('pause',pause);};},[key]);
|
||||
useEffect(()=>{if(video.current){video.current.muted=muted;video.current.volume=volume;}},[muted,volume,key,state]);
|
||||
function reconnect(){setError('');setPaused(false);setKey(v=>v+1);reveal();}
|
||||
const expired=error.includes(t('观看链接已失效'));
|
||||
function reconnect(){if(expired)return;setError('');setPaused(false);setKey(v=>v+1);reveal();}
|
||||
function togglePlay(){const el=video.current;if(!el)return;if(el.paused)void el.play().catch(()=>setError(t('无法播放,请重新连接。')));else el.pause();reveal();}
|
||||
async function toggleFullscreen(){try{if(document.fullscreenElement)await document.exitFullscreen();else if(root.current?.requestFullscreen)await root.current.requestFullscreen();else (video.current as HTMLVideoElement&{webkitEnterFullscreen?:()=>void})?.webkitEnterFullscreen?.();}catch{setError(t('此浏览器暂不支持全屏。'));}reveal();}
|
||||
return <main ref={root} tabIndex={-1} className={`cast-watch ${visible||state!=='playing'||paused?'controls-visible':''}`} onPointerMove={e=>{if(e.pointerType==='mouse')reveal();}} onClick={e=>{if(!(e.target as HTMLElement).closest('button,a,input')){reveal();}}} onFocusCapture={reveal} onBlurCapture={reveal} onKeyDown={e=>{if((e.target as HTMLElement).closest('button,a,input'))return;if(e.code==='Space'){e.preventDefault();togglePlay();}else if(e.key==='Escape')reveal();}}>
|
||||
<CastVideo key={key} active controls={false} videoRef={video} onState={setState} onError={setError} showStatus={false}/>
|
||||
<header className="cast-watch-top cast-watch-overlay"><a href="/manager#casting" className="cast-watch-icon" aria-label={t('返回投屏设置')} title={t('返回投屏设置')}><IconArrowLeft size={22}/></a><strong className="cast-watch-title">{t('头显画面')}</strong><span className="cast-watch-connection" role="status">{state==='playing'?t('实时画面'):state==='connecting'?t('正在连接'):t('连接已断开')}</span></header>
|
||||
{state!=='playing'&&<div className="cast-watch-message" role={state==='failed'?'alert':'status'}>{state==='connecting'?<><span className="cast-spinner"/><h1>{t('正在连接头显')}</h1><p>{t('画面即将开始,请稍候。')}</p></>:<><h1>{t('暂时无法观看')}</h1><p>{error||t('确认头显已开始串流,然后重新连接。')}</p><button className="primary" onClick={reconnect}><IconRefresh size={20}/>{t('重新连接')}</button></>}</div>}
|
||||
{state!=='playing'&&<div className="cast-watch-message" role={state==='failed'?'alert':'status'}>{state==='connecting'?<><span className="cast-spinner"/><h1>{t('正在连接头显')}</h1><p>{t('画面即将开始,请稍候。')}</p></>:<><h1>{t(expired?'观看链接已失效':'暂时无法观看')}</h1><p>{error||t('确认头显已开始串流,然后重新连接。')}</p>{!expired&&<button className="primary" onClick={reconnect}><IconRefresh size={20}/>{t('重新连接')}</button>}</>}</div>}
|
||||
{error&&state==='playing'&&<p className="cast-watch-toast" role="alert">{error}</p>}
|
||||
<footer className="cast-watch-bottom cast-watch-overlay"><div className="cast-watch-controls"><button className="cast-watch-icon" aria-label={t(paused?'播放':'暂停')} title={t(paused?'播放':'暂停')} disabled={state!=='playing'} onClick={togglePlay}>{paused?<IconPlayerPlay size={24}/>:<IconPlayerPause size={24}/>}</button><div className="cast-watch-sound"><button className="cast-watch-icon" aria-label={t(muted?'开启声音':'静音')} title={t(muted?'开启声音':'静音')} aria-pressed={!muted} onClick={()=>{setMuted(v=>!v);reveal();}}>{muted||volume===0?<IconVolumeOff size={23}/>:<IconVolume size={23}/>}</button><CastRange aria-label={t('音量')} min={0} max={1} step={.05} value={muted?0:volume} onChange={e=>{setVolume(+e.target.value);setMuted(false);reveal();}}/></div><span className="cast-watch-live">{t('实时')}</span><div className="cast-watch-tools"><button className="cast-watch-icon" aria-label={t('重新连接')} title={t('重新连接')} onClick={reconnect}><IconRefresh size={22}/></button><button className="cast-watch-icon" aria-label={t(fullscreen?'退出全屏':'全屏')} title={t(fullscreen?'退出全屏':'全屏')} onClick={()=>void toggleFullscreen()}>{fullscreen?<IconMinimize size={22}/>:<IconMaximize size={22}/>}</button></div></div></footer>
|
||||
</main>;
|
||||
|
||||
@@ -12,5 +12,8 @@
|
||||
.cast-choice-group small{white-space:normal;line-height:1.45;max-width:100%;text-align:center}.cast-framing-section{scroll-margin-top:16px}.cast-framing-stage{max-width:min(640px,calc(100dvh - 330px))}@media(max-width:767px){.cast-choice-group button{min-width:0}.cast-framing-stage{max-width:100%}.cast-watch-icon{min-height:44px}}
|
||||
.cast-framing-stage{max-width:min(640px,calc(100dvh - 400px))}.cast-editor-actions{position:sticky;bottom:-24px;background:transparent;padding:14px 0;z-index:6}@media(max-width:767px){.cast-choice-group button>span{white-space:normal}.cast-framing-stage{max-width:100%}.cast-editor-actions{bottom:-16px}}
|
||||
.cast-protocol-heading{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:6px}.cast-protocol-heading h2{margin:0}.cast-protocol-heading .ui-switch{flex-shrink:0}.cast-range{--range-fill:0%;appearance:none;-webkit-appearance:none;display:block;min-height:32px!important;height:32px!important;padding:0!important;margin:0;background:transparent!important;cursor:pointer;border:0!important;outline-offset:3px;touch-action:pan-y}.cast-range::-webkit-slider-runnable-track{height:6px;border-radius:999px;background:linear-gradient(to right,var(--blue) var(--range-fill),#484848 var(--range-fill))}.cast-range::-webkit-slider-thumb{appearance:none;-webkit-appearance:none;width:28px;height:16px;margin-top:-5px;border:0;border-radius:999px;background:var(--blue);box-shadow:0 1px 3px #0005}.cast-range::-moz-range-track{height:6px;border-radius:999px;background:#484848}.cast-range::-moz-range-progress{height:6px;border-radius:999px;background:var(--blue)}.cast-range::-moz-range-thumb{width:28px;height:16px;border:0;border-radius:999px;background:var(--blue)}.cast-range:disabled{opacity:.4;cursor:default}
|
||||
.cast-page-heading{flex-wrap:wrap}.cast-status{font-variant-numeric:tabular-nums;white-space:normal;text-align:right;margin-left:auto}
|
||||
.cast-stream-bar>.cast-stream-actions,.cast-stream-actions{display:flex;flex-direction:row;flex-wrap:wrap;align-items:center;gap:10px}.cast-quick-stream .cast-stream-actions{justify-content:center}.cast-record-button>svg{color:#ee565e}.cast-record-button.recording{border-color:#ee565e;color:#ffb4b8}.cast-record-path{font-size:12px;color:var(--muted);overflow-wrap:anywhere;margin:0 0 18px}.cast-record-button span{font-variant-numeric:tabular-nums}@media(max-width:767px){.cast-stream-bar{flex-wrap:wrap}.cast-stream-bar>.cast-stream-actions{width:100%}.cast-stream-actions>button{flex:1;white-space:nowrap}}
|
||||
.cast-stream-bar>.cast-stream-actions span{color:inherit;font-size:inherit;display:initial}.cast-quick-stream .cast-record-path{font-size:12px;margin:16px 0 0}
|
||||
/* Viewer: one restrained floating toolbar; the video always remains complete. */
|
||||
.cast-watch video{object-fit:contain!important}.cast-watch-top{padding-bottom:40px;gap:12px;background:linear-gradient(rgb(0 0 0/.55),transparent)}.cast-watch-title{font-size:14px;font-weight:500;color:#e9e9e9}.cast-watch-connection{font-size:12px;color:#c6c6c6}.cast-watch-bottom{padding-top:32px;background:transparent;display:flex;justify-content:center}.cast-watch-controls{width:auto;max-width:100%;padding:6px 12px;gap:8px;background:rgb(18 18 18/.88);border:1px solid rgb(255 255 255/.12);border-radius:999px;box-shadow:0 8px 28px #0005;backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px)}.cast-watch .cast-watch-icon{width:40px;height:40px;min-height:40px;border:0;border-radius:50%;padding:8px;background:transparent;color:#ededed}.cast-watch .cast-watch-icon:hover{background:#ffffff12}.cast-watch-top .cast-watch-icon{background:transparent}.cast-watch-top .cast-watch-icon:hover{background:#ffffff12}.cast-watch-sound{gap:6px}.cast-watch-sound input{width:80px}.cast-watch-live{margin:0 12px 0 8px;font-size:11px;color:#bcbcbc;letter-spacing:.02em}.cast-watch-tools{gap:4px;padding-left:8px;border-left:1px solid #ffffff1f}.cast-watch .cast-range{--blue:#e7e7e7}.cast-watch .cast-range::-webkit-slider-thumb{width:20px;height:12px;margin-top:-3px}.cast-watch .cast-range::-moz-range-thumb{width:20px;height:12px}.cast-watch-message h1{font-size:22px;font-weight:500}.cast-watch-message p{font-size:13px}.cast-watch-message button{border-radius:999px;min-height:40px;font-size:13px;padding:10px 18px}@media(max-width:767px){.cast-watch-bottom{padding-bottom:calc(16px + env(safe-area-inset-bottom))}.cast-watch-controls{gap:4px;padding:5px 9px}.cast-watch-top{padding-top:calc(12px + env(safe-area-inset-top));padding-bottom:32px}.cast-watch-live{display:inline;margin:0 10px 0 4px}.cast-watch-title{font-size:13px}.cast-watch-tools{padding-left:6px;gap:2px}}@media(prefers-reduced-transparency:reduce){.cast-watch-controls{background:#181818;backdrop-filter:none;-webkit-backdrop-filter:none}}
|
||||
+12
-7
@@ -5,21 +5,23 @@ import {api} from './api';
|
||||
import {Switch,SwitchRow} from './switch';
|
||||
import {CastRange} from './cast-range';
|
||||
import {t} from './i18n';
|
||||
import {CastVideo} from './cast-viewer';
|
||||
import {CastVideo,type CastStreamInfo} from './cast-viewer';
|
||||
import {FramingEditor} from './cast-framing-editor';
|
||||
import {framePresets,frameRatios,type ReferenceView} from './cast-framing';
|
||||
import './casting.css';
|
||||
import {RecordingButton,type RecordingStatus} from './cast-recording';
|
||||
|
||||
export type CastSettings={source:'screen'|'steamVR';codec:'h264'|'h265';output:'eye'|'raw';eye:'steamVR'|'left'|'right';width:number;height:number;fps:number;bitrateMbps:number;horizontalFov:number|null;centerX:number;centerY:number;systemAudio:boolean;microphone:boolean;airplay:boolean;dlna:boolean;receiverName:string};
|
||||
type CastSession={id:string;protocol:string;accepted:boolean;width?:number;height?:number};
|
||||
type Device={id:string;name:string};
|
||||
type Runtime={running:boolean;error:string|null;dominantEye:number|null;watchPath:string|null;elapsedSeconds:number|null;preview?:boolean;view?:ReferenceView|null;receiverError?:string|null;sessions?:CastSession[];watchAddresses?:string[]};
|
||||
type Runtime={running:boolean;recording?:RecordingStatus;error:string|null;dominantEye:number|null;watchPath:string|null;watchAccessEnabled?:boolean;elapsedSeconds:number|null;preview?:boolean;view?:ReferenceView|null;receiverError?:string|null;sessions?:CastSession[];watchAddresses?:string[]};
|
||||
export type CastPage='casting'|'casting-picture'|'casting-receive'|'casting-devices';
|
||||
const titles:Record<CastPage,[string,string]>={casting:['网页观看','把头显画面分享给同一网络中的手机或电脑。'],'casting-picture':['画面与声音','选择画面来源,直接调整取景和输出画质。'],'casting-receive':['接收投屏','接收其他设备的音频和视频,在头显中打开。'],'casting-devices':['投到设备','选择同一网络中的设备,播放当前头显画面。']};
|
||||
function Choices<T extends string|number>({label,value,options,onChange,disabled=false}:{label:string;value:T;options:{value:T;label:string;description?:string}[];onChange:(value:T)=>void;disabled?:boolean}){
|
||||
return <div className="cast-choice-group" role="radiogroup" aria-label={label}>{options.map((option,i)=><button type="button" role="radio" aria-checked={value===option.value} className={value===option.value?'selected':''} disabled={disabled} key={option.value} onClick={()=>onChange(option.value)} onKeyDown={e=>{if(['ArrowLeft','ArrowRight','ArrowUp','ArrowDown'].includes(e.key)){e.preventDefault();const next=(i+(['ArrowLeft','ArrowUp'].includes(e.key)?-1:1)+options.length)%options.length;onChange(options[next].value);(e.currentTarget.parentElement?.children[next] as HTMLElement)?.focus();}}}><span>{option.label}</span>{option.description&&<small>{option.description}</small>}</button>)}</div>;
|
||||
}
|
||||
export function Casting({config,refresh,network,page='casting'}:{config:CastSettings;refresh:()=>Promise<void>;network:NetworkPanel;page?:CastPage}){
|
||||
const [streamInfo,setStreamInfo]=useState<CastStreamInfo|null>(null);
|
||||
const [draft,setDraft]=useState(config),[runtime,setRuntime]=useState<Runtime|null>(null),[pending,setPending]=useState(''),[error,setError]=useState(''),[saved,setSaved]=useState(false),[epoch,setEpoch]=useState(0),[devices,setDevices]=useState<Device[]>([]),[sending,setSending]=useState<string>(''),[copied,setCopied]=useState(false),[editing,setEditing]=useState(false),[searched,setSearched]=useState(false);
|
||||
const beforeFraming=useRef<Pick<CastSettings,'horizontalFov'|'centerX'|'centerY'>|null>(null),editorActive=useRef(false),deviceScan=useRef(0),alive=useRef(true);
|
||||
const captureSettings=({airplay,dlna,receiverName,...capture}:CastSettings)=>capture;
|
||||
@@ -37,17 +39,20 @@ export function Casting({config,refresh,network,page='casting'}:{config:CastSett
|
||||
useEffect(()=>{if(page!=='casting-picture'&&editorActive.current)void action('preview',cancelFraming);},[page]);
|
||||
async function scan(){const token=++deviceScan.current;setSearched(true);for(let i=0;i<3;i++){const list=await api<Device[]>('cast.devices');if(token!==deviceScan.current||!alive.current)return;setDevices(list);if(i<2)await new Promise(resolve=>setTimeout(resolve,1000));}}
|
||||
useEffect(()=>{if(page==='casting-devices')void action('scan',scan);},[page]);
|
||||
async function copy(){const address=['localhost','127.0.0.1','[::1]'].includes(location.hostname)?runtime?.watchAddresses?.[0]??new URL('/cast/watch',location.origin).href:new URL('/cast/watch',location.origin).href;if(navigator.clipboard)await navigator.clipboard.writeText(address);else{const input=document.createElement('textarea');input.value=address;document.body.append(input);input.select();document.execCommand('copy');input.remove();}setCopied(true);setTimeout(()=>setCopied(false),2500);}
|
||||
const watchAddress=['localhost','127.0.0.1','[::1]'].includes(location.hostname)?runtime?.watchAddresses?.[0]??new URL(runtime?.watchPath??'/cast/watch',location.origin).href:new URL(runtime?.watchPath??'/cast/watch',location.origin).href;
|
||||
async function copy(){if(navigator.clipboard)await navigator.clipboard.writeText(watchAddress);else{const input=document.createElement('textarea');input.value=watchAddress;document.body.append(input);input.select();document.execCommand('copy');input.remove();}setCopied(true);setTimeout(()=>setCopied(false),2500);}
|
||||
const errorMessage=error||(page==='casting-receive'||page==='casting-devices'?runtime?.receiverError:runtime?.error);
|
||||
const presets=framePresets(aspect);
|
||||
function chooseAspect(value:number){const edge=Math.max(draft.width,draft.height);const size=value>=1?{width:edge,height:Math.round(edge/value/2)*2}:{width:Math.round(edge*value/2)*2,height:edge};change(size);}
|
||||
return <div className="casting cast-workspace">
|
||||
<header className="cast-page-heading"><div><h1>{t(titles[page][0])}</h1><p>{t(titles[page][1])}</p></div>{page==='casting'&&<span className={`cast-status ${runtime?.running?'live':''}`} role="status">{pending==='start'?t('正在启动…'):runtime?.running?t('正在串流'):t('未开始串流')}</span>}</header>
|
||||
<header className="cast-page-heading"><div><h1>{t(titles[page][0])}</h1><p>{t(titles[page][1])}</p></div>{page==='casting'&&<span className={`cast-status ${runtime?.running?'live':''}`} title={t('当前浏览器实际接收并解码的串流信息')} role="status">{pending==='start'?t('正在启动…'):runtime?.running?streamInfo?<>{streamInfo.width&&streamInfo.height?`${streamInfo.width} × ${streamInfo.height}`:'—'} · {streamInfo.codec??'—'} · {streamInfo.fps===null?'—':streamInfo.fps.toFixed(1)} fps</>:t('正在读取串流信息…'):t('未开始串流')}</span>}</header>
|
||||
{errorMessage&&<div className="cast-error" role="alert"><strong>{t('操作未完成')}</strong><p>{errorMessage}</p></div>}
|
||||
{page==='casting'&&<>
|
||||
<div className="cast-monitor"><CastVideo key={epoch} active={!!runtime?.running&&!runtime.preview} onError={setError}/>{!runtime?.running&&<div className="cast-monitor-empty"><IconDeviceVisionPro size={44} stroke={1.5}/><h2>{t('分享你看到的画面')}</h2><p>{t('启动后,手机和电脑可以通过网页实时观看。')}</p></div>}</div>
|
||||
<div className="cast-stream-bar"><div><strong>{t(config.source==='screen'?'屏幕采集':'SteamVR')}</strong><span>{config.width} × {config.height}<i/> {config.fps} fps</span></div><button className={runtime?.running?'':'primary'} disabled={busy||!runtime} onClick={()=>void action('start',async()=>{if(runtime?.running)await api('cast.stop');else{if(dirty)await apply();await api('cast.start');}setEpoch(v=>v+1);})}>{runtime?.running?<IconPlayerStop size={21}/>:<IconPlayerPlay size={21}/>}<span>{pending==='start'?t('正在启动…'):runtime?.running?t('停止串流'):t('开始串流')}</span></button></div>
|
||||
{runtime?.running&&<section className="cast-share"><div><IconWifi size={24}/><div><h2>{t('在其他设备上观看')}</h2><p>{t('连接同一网络,打开观看地址。')}</p></div></div><div className="cast-share-link"><code>{(!['localhost','127.0.0.1','[::1]'].includes(location.hostname)?new URL('/cast/watch',location.origin).href:runtime.watchAddresses?.[0])??new URL('/cast/watch',location.origin).href}</code><button onClick={()=>void action('copy',copy)} aria-label={t('复制观看地址')}>{copied?<IconCheck size={20}/>:<IconCopy size={20}/>}<span>{copied?t('已复制'):t('复制')}</span></button><a className="cast-icon-link" href="/cast/watch" aria-label={t('打开观看网页')}><IconExternalLink size={21}/></a></div></section>}
|
||||
<div className="cast-monitor"><CastVideo key={epoch} active={!!runtime?.running&&!runtime.preview} onError={setError} onStreamInfo={setStreamInfo}/>{!runtime?.running&&<div className="cast-monitor-empty"><IconDeviceVisionPro size={44} stroke={1.5}/><h2>{t('分享你看到的画面')}</h2><p>{t('启动后,手机和电脑可以通过网页实时观看。')}</p></div>}</div>
|
||||
<div className="cast-stream-bar"><div><strong>{t(config.source==='screen'?'屏幕采集':'SteamVR')}</strong><span>{config.width} × {config.height}<i/> {config.fps} fps</span></div><div className="cast-stream-actions"><button className={runtime?.running?'':'primary'} disabled={busy||!runtime} onClick={()=>void action('start',async()=>{if(runtime?.running)await api('cast.stop');else{if(dirty)await apply();await api('cast.start');}setEpoch(v=>v+1);})}>{runtime?.running?<IconPlayerStop size={21}/>:<IconPlayerPlay size={21}/>}<span>{pending==='start'?t('正在启动…'):runtime?.running?t('停止串流'):t('开始串流')}</span></button><RecordingButton recording={runtime?.recording} disabled={busy||!runtime||!!runtime.preview} onClick={()=>void action('record',async()=>{if(!runtime?.recording?.active&&!runtime?.running&&dirty)await apply();await api(runtime?.recording?.active?'cast.record.stop':'cast.record.start');setEpoch(v=>v+1);})}/></div></div>
|
||||
<p className="cast-record-path">{t('录制保存到 /home/steamos/Videos/framely')}{runtime?.recording?.path&&<> · <span>{runtime.recording.path.split('/').pop()}</span></>}</p>
|
||||
{runtime?.recording?.error&&<p className="error" role="alert">{runtime.recording.error}</p>}
|
||||
{runtime?.running&&<section className="cast-share"><div><IconWifi size={24}/><div><h2>{t('在其他设备上观看')}</h2><p>{t('连接同一网络,打开观看地址。')}</p></div></div><SwitchRow label={t('免验证')} checked={runtime.watchAccessEnabled??false} disabled={busy} onChange={enabled=>void action('watch-access',async()=>{await api('cast.watchAccess.save',{enabled});setCopied(false);})}/><p>{t('持有此链接的人无需密码即可观看。')}</p><div className="cast-share-link"><code>{watchAddress}</code><button disabled={busy} onClick={()=>void action('copy',copy)} aria-label={t('复制观看地址')}>{copied?<IconCheck size={20}/>:<IconCopy size={20}/>}<span>{copied?t('已复制'):t('复制')}</span></button><a className="cast-icon-link" href={watchAddress} aria-label={t('打开观看网页')}><IconExternalLink size={21}/></a></div></section>}
|
||||
|
||||
</>}
|
||||
{page==='casting-picture'&&<>
|
||||
|
||||
Reference in new issue
Block a user