[feat] add mobile codec status and scrolling codec names

This commit is contained in:
dijunkun
2026-10-27 04:16:41 +08:00
parent 1687816bde
commit e123d5f2cc
20 changed files with 241 additions and 48 deletions
@@ -195,6 +195,10 @@ final class NativeSession implements AutoCloseable {
fail(status >= 0 && status < reasons.length ? reasons[status] : "连接已结束");
}
} else if (type == 4) {
if(event.optInt("type",-1)==14){
networkStatistics.receiveRemoteEncoder(event.getJSONObject("video_codec").getString("encoder"));
return;
}
JSONObject info=event.optJSONObject("host_info");
if(info!=null)supportsTextInput=info.optBoolean("supports_text_input",false);
ui(() -> {
@@ -12,15 +12,18 @@ final class RemoteNetworkStatistics {
}
static final class Report {
final Traffic[] traffic=new Traffic[4];final int mode;final boolean srtp;final double rtt;
final String decoder;
Report(JSONObject json){
String[] keys={"video","audio","data","total"};
for(int i=0;i<keys.length;i++){JSONObject item=json.optJSONObject(keys[i]);traffic[i]=item==null?null:new Traffic(item);}
mode=json.optInt("mode",2);srtp=json.optBoolean("srtp");rtt=json.optDouble("rtt",Double.NaN);
decoder=json.optString("decoder","");
}
}
static final class Snapshot {
final Report report;final int fps,width,height;final double latency,rtt;
Snapshot(Report report,int fps,int width,int height,double latency,double rtt){this.report=report;this.fps=fps;this.width=width;this.height=height;this.latency=latency;this.rtt=rtt;}
final String remoteEncoder,localDecoder;
Snapshot(Report report,int fps,int width,int height,double latency,double rtt,String remoteEncoder,String localDecoder){this.report=report;this.fps=fps;this.width=width;this.height=height;this.latency=latency;this.rtt=rtt;this.remoteEncoder=remoteEncoder;this.localDecoder=localDecoder;}
String mode(){return report==null?"—":report.mode==0?"P2P 直连":report.mode==1?"TURN 中继":"—";}
String resolution(){return width>0&&height>0?width+" × "+height:"—";}
String summary(){
@@ -32,11 +35,13 @@ final class RemoteNetworkStatistics {
final double time,latency;Frame(double time,double latency){this.time=time;this.latency=latency;}
}
private Report report;
private String remoteEncoder="";
private double reportTime,rttTime,averageRtt=Double.NaN;
private long lastFrameId;
private int width,height;
private final ArrayDeque<Frame> frames=new ArrayDeque<>();
static double now(){return System.nanoTime()/1_000_000_000.0;}
synchronized void receiveRemoteEncoder(String encoder){remoteEncoder=encoder;}
synchronized void receive(Report value,double now){
report=value;reportTime=now;
if(!Double.isFinite(value.rtt)||value.rtt<0||value.rtt>2000)return;
@@ -53,7 +58,7 @@ final class RemoteNetworkStatistics {
synchronized Snapshot snapshot(double now){
prune(now);double total=0;int count=0;
for(Frame frame:frames)if(Double.isFinite(frame.latency)){total+=frame.latency;count++;}
return new Snapshot(now-reportTime<3?report:null,frames.size(),width,height,count==0?Double.NaN:total/count,now-rttTime<3?averageRtt:Double.NaN);
return new Snapshot(now-reportTime<3?report:null,frames.size(),width,height,count==0?Double.NaN:total/count,now-rttTime<3?averageRtt:Double.NaN,remoteEncoder,report==null?"":report.decoder);
}
private void prune(double now){while(!frames.isEmpty()&&now-frames.peekFirst().time>=1)frames.removeFirst();}
static String bitrate(long value){return value<0?"—":value>=1_000_000?String.format(Locale.ROOT,"%.1f Mbps",value/1_000_000.0):value>=1000?String.format(Locale.ROOT,"%.0f Kbps",value/1000.0):value+" bps";}
@@ -107,7 +107,7 @@ final class SessionControls extends FrameLayout {
}
void setMuted(boolean value){muted=value;if(menu!=null&&menuPage.equals("controls"))openMenu("controls");}
void videoSize(int width,int height){waiting.setVisibility(GONE);positionMouse();}
void resetVideoStatistics(){statistics(new RemoteNetworkStatistics.Snapshot(network.report,0,0,0,Double.NaN,network.rtt));}
void resetVideoStatistics(){statistics(new RemoteNetworkStatistics.Snapshot(network.report,0,0,0,Double.NaN,network.rtt,network.remoteEncoder,network.localDecoder));}
void statistics(RemoteNetworkStatistics.Snapshot value){network=value;renderNetwork();}
void videoSettings(RemoteVideoSettings value){settings=value;renderVideoSettings();}
void privacyScreen(RemotePrivacyScreen value){privacy=value;renderPrivacy();}
@@ -200,10 +200,12 @@ final class SessionControls extends FrameLayout {
for(int i=0;i<heading.getChildCount();i++)((TextView)heading.getChildAt(i)).setTextColor(SECONDARY);
for(int i=0;i<4;i++)trafficRows[i]=trafficRow(body,names[i],trafficCells[i],i==3);
ui.gap(body,10);ui.divider(body);ui.gap(body,10);
String[] titles={"帧率","分辨率","画面延时","连接延时(RTT)","连接方式","媒体加密"};networkDetails=new TextView[titles.length];
for(int i=0;i<titles.length;i++){LinearLayout row=ui.row();TextView title=ui.text(titles[i],12,false);title.setTextColor(SECONDARY);row.addView(title,new LinearLayout.LayoutParams(0,-2,1));
String[] titles={"帧率","分辨率","画面延时","连接延时(RTT)","连接方式","媒体加密","被控端编码器","控制端解码器"};networkDetails=new TextView[titles.length];
for(int i=0;i<titles.length;i++){LinearLayout row=ui.row();TextView title=ui.text(titles[i],12,false);title.setTextColor(SECONDARY);row.addView(title,i>=6?new LinearLayout.LayoutParams(-2,-2):new LinearLayout.LayoutParams(0,-2,1));
if(i==5){encryptionLock=ui.icon("lock",GREEN);encryptionOpen=ui.icon("unlock",SECONDARY);for(View icon:new View[]{encryptionLock,encryptionOpen}){LinearLayout.LayoutParams p=ui.size(14,14);p.rightMargin=ui.dp(4);row.addView(icon,p);}}
TextView value=ui.text("—",12,false);value.setFontFeatureSettings("tnum");row.addView(value);networkDetails[i]=value;body.addView(row);if(i<titles.length-1)ui.gap(body,7);}
TextView value=ui.text("—",12,false);value.setFontFeatureSettings("tnum");
if(i>=6){value.setGravity(Gravity.END);value.setSingleLine();value.setEllipsize(android.text.TextUtils.TruncateAt.MARQUEE);value.setMarqueeRepeatLimit(-1);value.setSelected(true);LinearLayout.LayoutParams p=new LinearLayout.LayoutParams(0,-2,1);p.leftMargin=ui.dp(8);row.addView(value,p);}else row.addView(value);
networkDetails[i]=value;body.addView(row);if(i<titles.length-1)ui.gap(body,7);}
renderNetwork();
}
private LinearLayout trafficRow(LinearLayout body,String title,TextView[] cells,boolean total){
@@ -222,8 +224,9 @@ final class SessionControls extends FrameLayout {
String in=RemoteNetworkStatistics.bitrate(traffic==null?-1:traffic.inbound),out=RemoteNetworkStatistics.bitrate(traffic==null?-1:traffic.outbound),loss=RemoteNetworkStatistics.loss(traffic==null?Double.NaN:traffic.loss);
trafficCells[i][0].setText(in);trafficCells[i][1].setText(out);trafficCells[i][2].setText(loss);trafficRows[i].setContentDescription(names[i]+",接收 "+in+",发送 "+out+",丢包率 "+loss);
}
String[] values={network.fps+" FPS",network.resolution(),RemoteNetworkStatistics.latency(network.latency),RemoteNetworkStatistics.latency(network.rtt),network.mode(),network.report==null?"—":network.report.srtp?"SRTP 已启用":"未启用"};
for(int i=0;i<values.length;i++)networkDetails[i].setText(values[i]);networkDetails[5].setTextColor(network.report!=null&&network.report.srtp?GREEN:SECONDARY);
String[] values={network.fps+" FPS",network.resolution(),RemoteNetworkStatistics.latency(network.latency),RemoteNetworkStatistics.latency(network.rtt),network.mode(),network.report==null?"—":network.report.srtp?"SRTP 已启用":"未启用",network.remoteEncoder.isEmpty()?"—":network.remoteEncoder,network.localDecoder.isEmpty()?"—":network.localDecoder};
// Reassigning identical text every statistics tick restarts the marquee.
for(int i=0;i<values.length;i++)if(!android.text.TextUtils.equals(networkDetails[i].getText(),values[i]))networkDetails[i].setText(values[i]);networkDetails[5].setTextColor(network.report!=null&&network.report.srtp?GREEN:SECONDARY);
encryptionLock.setVisibility(network.report!=null&&network.report.srtp?VISIBLE:GONE);encryptionOpen.setVisibility(network.report!=null&&!network.report.srtp?VISIBLE:GONE);
}
private int controlColumns(){return getMeasuredHeight()<ui.dp(400)&&getMeasuredWidth()>=ui.dp(400)?4:3;}
+1
View File
@@ -371,6 +371,7 @@ extern "C" JNIEXPORT jboolean JNICALL JNI_METHOD(nConnect)(
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nReady)(JNIEnv*, jclass, jlong handle) {
auto* s = From(handle); if (!s || !s->controller) return;
auto action = MakeHostInformation("CrossDesk Android", {}, true, "1.6.0", HostPlatform::Android);
action.i.supports_video_codec_status = true;
s->Send(kControlStream, action.to_json()); FreeRemoteAction(action);
}
extern "C" JNIEXPORT void JNICALL JNI_METHOD(nPointer)(
+32
View File
@@ -1,5 +1,37 @@
import { FlatSurface, UiSurface } from "ui_surface.slint";
import { UiRadius } from "ui_radius.slint";
// Keep codec names readable in narrow status panels without changing row height.
export component ScrollingText inherits Rectangle {
in property <string> text;
in property <color> text-color;
in property <length> font-size: 11px;
in property <int> font-weight: 400;
in property <bool> align-right: false;
private property <length> distance: max(0px, label.preferred-width - root.width);
private property <float> phase: 0;
min-width: 0px;
clip: true;
accessible-role: AccessibleRole.text;
accessible-label: root.text;
changed text => { root.phase = 0; }
changed width => { root.phase = 0; }
Timer {
interval: 16ms;
running: root.visible && root.width > 0px && root.distance > 0px;
// 30 logical pixels/second, with a one-second pause at each end.
triggered => { root.phase = mod(root.phase + 0.48, root.distance / 1px + 60); }
}
label := Text {
x: root.distance > 0px ? -min(root.distance, max(0, root.phase - 30) * 1px)
: root.align-right ? root.width - self.preferred-width : 0px;
width: self.preferred-width; height: root.height;
text: root.text; color: root.text-color;
font-size: root.font-size; font-weight: root.font-weight;
wrap: no-wrap; vertical-alignment: center;
}
}
export global FloatingBarStyle {
out property <length> corner-radius: UiRadius.menu;
}
@@ -1,7 +1,7 @@
import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.slint";
import { UiRadius } from "ui_radius.slint";
import { GalleryStrings } from "device_strings.slint";
import { CompactButton } from "common.slint";
import { CompactButton, ScrollingText } from "common.slint";
import { ScrollView } from "std-widgets.slint";
export struct GalleryNetworkDevice {
@@ -153,18 +153,26 @@ component NetworkRow inherits Rectangle {
spacing: 12px;
Rectangle {
width: (parent.width - 12px) / 2;
Text {
width: parent.width; height: parent.height;
text: GalleryStrings.tr("被控端编码器 ", "Remote encoder ", "Удалённый кодер ") + root.device.encoder;
font-size: 10px; color: #586f87; vertical-alignment: center; overflow: elide;
encoder-label := Text {
height: parent.height; width: self.preferred-width;
text: GalleryStrings.tr("被控端编码器 ", "Remote encoder ", "Удалённый кодер ");
font-size: 10px; color: #586f87; vertical-alignment: center;
}
ScrollingText {
x: encoder-label.width; width: max(0px, parent.width - self.x); height: parent.height;
text: root.device.encoder; font-size: 10px; text-color: #586f87;
}
}
Rectangle {
width: (parent.width - 12px) / 2;
Text {
width: parent.width; height: parent.height;
text: GalleryStrings.tr("控制端解码器 ", "Local decoder ", "Локальный декодер ") + root.device.decoder;
font-size: 10px; color: #586f87; vertical-alignment: center; overflow: elide;
decoder-label := Text {
height: parent.height; width: self.preferred-width;
text: GalleryStrings.tr("控制端解码器 ", "Local decoder ", "Локальный декодер ");
font-size: 10px; color: #586f87; vertical-alignment: center;
}
ScrollingText {
x: decoder-label.width; width: max(0px, parent.width - self.x); height: parent.height;
text: root.device.decoder; font-size: 10px; text-color: #586f87;
}
}
}
+4 -5
View File
@@ -2,7 +2,7 @@ import { SurfaceSeparator, FlatSurface, UiSurface } from "ui_surface.slint";
import { UiRadius } from "ui_radius.slint";
import { ComboBox, ScrollView } from "std-widgets.slint";
import { RemoteCursor } from "remote_cursor.slint";
import { FontAwesomeIcons, IconButton, PrimaryButton, SecondaryButton, DialogSurface, ImGuiFontStyle, ControlStyle, ButtonStyle } from "common.slint";
import { FontAwesomeIcons, IconButton, PrimaryButton, SecondaryButton, DialogSurface, ImGuiFontStyle, ControlStyle, ButtonStyle, ScrollingText } from "common.slint";
export struct StreamTab {
remote-id: string,
@@ -1458,13 +1458,12 @@ export component StreamWindow inherits Window {
color: #667085; font-size: ImGuiFontStyle.small;
vertical-alignment: center;
}
Text {
ScrollingText {
x: codec-label-width-probe.preferred-width + 8px; y: 0px;
width: parent.width - self.x; height: parent.height;
text: codec.value; color: #344054;
text: codec.value; text-color: #344054;
font-size: ImGuiFontStyle.body; font-weight: 500;
horizontal-alignment: right;
vertical-alignment: center; overflow: elide;
align-right: true;
}
}
}
@@ -48,6 +48,8 @@ int main() {
auto& adapter = *backend->adapter;
stream->window().dispatch_resize_event(slint::LogicalSize({1280, 720}));
stream->show();
stream->set_secure_attention_supported(false);
stream->set_privacy_visible(true);
auto rows = std::make_shared<slint::VectorModel<crossdesk::ui::NetworkStatsRow>>();
stream->set_stats_rows(rows);
std::vector<crossdesk::ui::NetworkStatsRow> stats(4);
@@ -75,6 +77,13 @@ int main() {
};
settle();
const float top = stream->get_video_area_top();
// The initial control is only the logo. Wait for the toolbar reveal before
// clicking network status, just as a user does in a new session.
const slint::LogicalPosition logo({19, top + 19});
stream->window().dispatch_pointer_move_event(logo);
stream->window().dispatch_pointer_press_event(logo, slint::PointerEventButton::Left);
stream->window().dispatch_pointer_release_event(logo, slint::PointerEventButton::Left);
settle(20);
for (bool dock_right : {false, true}) {
if (dock_right) {
stream->invoke_begin_control_drag(5, 20);
@@ -82,19 +91,26 @@ int main() {
stream->invoke_end_control_drag();
settle();
}
const int control_x = dock_right ? 916 : 0;
const slint::LogicalPosition button({float(control_x + (dock_right ? 278 : 245)), top + 19});
// With privacy visible and no Windows shortcuts, the toolbar is 344 px.
// Its logo moves to the trailing edge when docked on the right.
const int control_x = dock_right ? int(stream->get_video_area_width()) - 344 : 0;
const slint::LogicalPosition button({float(control_x + (dock_right ? 214 : 250)), top + 19});
stream->window().dispatch_pointer_move_event(button);
settle(35); // Include the tooltip that appears before the click.
for (bool expanded : {true, false, true, false}) {
stream->window().dispatch_pointer_press_event(button, slint::PointerEventButton::Left);
stream->window().dispatch_pointer_release_event(button, slint::PointerEventButton::Left);
settle(); // No pointer movement: animation must finish on its own.
if (stats_visible != expanded) {
std::cerr << "Network status button did not toggle the panel; dock_right="
<< dock_right << '\n';
return 1;
}
for (int y : {65, 195}) {
const auto pixel = adapter.pixels[(int(top) + y) * 1280 + control_x + 4];
const bool painted = pixel.r >= 240 && pixel.g >= 240 && pixel.b >= 240;
const bool cleared = pixel.r == 0 && pixel.g == 0 && pixel.b == 0;
if (stats_visible != expanded || (expanded ? !painted : !cleared)) {
if (expanded ? !painted : !cleared) {
std::cerr << "Stats must fully " << (expanded ? "expand" : "collapse")
<< " without pointer movement; dock_right=" << dock_right
<< ", y=" << y << ", pixel=" << int(pixel.r) << ','
+35 -13
View File
@@ -779,7 +779,8 @@ bool VerifyWorkbenchActions() {
if (window->get_settings_tab() != 0) return false;
window->set_gallery_settings_error("");
window->set_settings_open(false);
click(60, 20);
// DeskHub follows the ID/IP mode switch when direct IP connections are enabled.
click(window->get_ip_direct_enabled() ? 204 : 60, 20);
if (!gallery_opened) return false;
crossdesk::ui::RecentConnection device{};
device.remote_id = "987654321";
@@ -850,24 +851,45 @@ bool VerifyWorkbenchActions() {
bool VerifySystemShortcutMenu() {
auto stream = crossdesk::ui::StreamWindow::create();
stream->window().set_size(slint::LogicalSize(slint::Size<float>{1280, 720}));
stream->show();
std::vector<std::string> commands;
stream->on_send_shortcut([&](slint::SharedString command) { commands.emplace_back(command); });
const auto click = [&](float y) {
const slint::LogicalPosition point({55, stream->get_video_area_top() + y});
const auto settle = [] {
for (int i = 0; i < 20; ++i) {
std::this_thread::sleep_for(std::chrono::milliseconds(16));
slint::platform::update_timers_and_animations();
}
};
const auto click = [&](float x, float y) {
const slint::LogicalPosition point({x, stream->get_video_area_top() + y});
stream->window().dispatch_pointer_move_event(point);
stream->window().dispatch_pointer_press_event(point, slint::PointerEventButton::Left);
stream->window().dispatch_pointer_release_event(point, slint::PointerEventButton::Left);
};
click(20);
if (!stream->invoke_is_local_control_area(55, 90)) return false;
click(58);
stream->set_secure_attention_supported(false);
click(20);
if (stream->invoke_is_local_control_area(55, 90)) return false;
click(58);
// The toolbar starts collapsed. Reveal it before targeting its buttons.
click(19, 19);
settle();
stream->set_secure_attention_supported(true);
click(20); click(88);
return commands == std::vector<std::string>{"Ctrl+Alt+Del", "Win+L", "Win+L"};
settle();
click(90, 19);
if (!stream->invoke_is_local_control_area(90, 90)) return false;
click(90, 68);
// Switching away from a Windows host hides the entire shortcut entry and
// closes an open menu; its former rows must no longer dispatch commands.
click(90, 19);
if (!stream->invoke_is_local_control_area(90, 90)) return false;
stream->set_secure_attention_supported(false);
settle();
if (stream->invoke_is_local_control_area(90, 90)) return false;
click(90, 98);
if (commands != std::vector<std::string>{"Ctrl+Alt+Del"}) return false;
stream->set_secure_attention_supported(true);
settle();
click(90, 19);
click(90, 98);
return commands == std::vector<std::string>{"Ctrl+Alt+Del", "Win+L"};
}
bool VerifyRemoteIdEditing() {
@@ -905,7 +927,7 @@ bool VerifyRemoteIdEditing() {
int main() {
if (!VerifySystemShortcutMenu()) {
std::cerr << "Shortcut menu must omit unsupported SAS and resize its input area\n";
std::cerr << "Shortcut menu must dispatch Windows shortcuts and hide for unsupported hosts\n";
return 13;
}
if (!VerifyRemoteIdEditing()) {
@@ -99,10 +99,11 @@ export class VideoSettings {
feedback(): string { return !this.supported ? '连接建立后可调整,需被控端支持。' : this.failed ? '调整失败,请重试。' : this.pending ? '等待电脑端确认…' : ''; }
}
export interface Traffic { inbound: number; outbound: number; loss: number; }
export interface NetworkReport { rtt: number; mode: number; srtp: boolean; video: Traffic; audio: Traffic; data: Traffic; total: Traffic; }
export interface NetworkReport { rtt: number; mode: number; srtp: boolean; decoder?: string; video: Traffic; audio: Traffic; data: Traffic; total: Traffic; }
export interface FrameSample { time: number; count: number; latency: number; latencyCount: number; }
export class NetworkStatistics {
report?: NetworkReport;
remoteEncoder: string = '';
reportTime: number = -Infinity;
rttTime: number = -Infinity;
rtt: number = -1;
@@ -26,7 +26,8 @@ export interface RemoteDisplay { name: string; }
interface HostInfo { host_name: string; platform: string; app_version: string; supports_video_settings: boolean; displays: RemoteDisplay[]; }
interface SettingAck { quality: number; frame_rate: number; preference: number; request_id: number; accepted: boolean; }
interface ServiceStatus { available: boolean; interactive_stage?: string; }
interface ControlData { type: number; host_info?: HostInfo; video_settings?: SettingAck; service_status?: ServiceStatus; privacy_status?: PrivacyStatus; }
interface VideoCodecStatus { encoder: string; }
interface ControlData { type: number; host_info?: HostInfo; video_settings?: SettingAck; service_status?: ServiceStatus; privacy_status?: PrivacyStatus; video_codec?: VideoCodecStatus; }
interface PresenceRequest { type: string; devices: string[]; subscribe: boolean; }
interface SignalType { type: string; }
@@ -465,6 +466,7 @@ export class MobileController {
if (this.receivedHostInfo) this.checkRemote(this.remoteVersion);
}
private data(message: ControlData): void {
if (message.type === 14 && message.video_codec) { this.network.remoteEncoder = message.video_codec.encoder; return; }
if (message.type === 10 && message.privacy_status) { this.privacyScreen.receive(message.privacy_status, this.now()); this.syncPrivacy(); return; }
if (message.type === 5) {
this.serviceAvailable = message.service_status?.available === true;
@@ -198,6 +198,15 @@ export struct MobileRemoteView {
}.width('100%').constraintSize({ minHeight: 21 })
}
@Builder
NetworkCodec(label: string, value: string) {
Row({ space: 8 }) {
Text(label).fontSize(12).fontColor(MobileTheme.secondary).maxLines(1)
Text(value).fontSize(12).fontColor(MobileTheme.ink).textAlign(TextAlign.End).layoutWeight(1)
.maxLines(1).textOverflow({ overflow: TextOverflow.MARQUEE })
.marqueeOptions({ start: true, step: 0.5, loop: -1, fromStart: true })
}.width('100%').constraintSize({ minHeight: 21 })
}
@Builder
Menu() {
Column({ space: 10 }) {
Row({ space: 6 }) {
@@ -266,7 +275,8 @@ export struct MobileRemoteView {
this.NetworkDetail('连接延时(RTT)', NetworkStatistics.delay(this.state.networkTime - (this.controller?.network.rttTime ?? 0) < 3000 ? this.controller?.network.rtt ?? -1 : -1))
this.NetworkDetail('连接方式', this.controller?.network.mode(this.state.networkTime) ?? '—')
this.NetworkDetail('媒体加密', this.controller?.network.current(this.state.networkTime) === undefined ? '—' : this.controller?.network.report?.srtp ? 'SRTP 已启用' : '未启用', this.controller?.network.current(this.state.networkTime)?.srtp === true)
this.NetworkCodec('被控端编码器', this.controller?.network.remoteEncoder || '—')
this.NetworkCodec('控制端解码器', this.controller?.network.report?.decoder || '—')
}
}.width('100%')
}.constraintSize({ maxHeight: Math.max(48, this.viewHeight - 132) }).width('100%').scrollBar(BarState.Off)
+1
View File
@@ -144,6 +144,7 @@ class MobileSession {
if (status == Connected && !s->connected_) {
s->connecting_ = false; s->connected_ = true;
auto info = MakeHostInformation("CrossDesk HarmonyOS Mobile", {}, true, s->version_, HostPlatform::HarmonyOS);
info.i.supports_video_codec_status = true;
s->Send(info); FreeRemoteAction(info);
RemoteAction audio{}; audio.type = audio_capture; audio.a = !s->muted_; s->Send(audio);
s->audio_.Start();
@@ -136,7 +136,10 @@ typedef struct {
didReceiveFileAtURL:(NSURL *)fileURL;
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didUpdateNetworkStats:(CrossDeskNetworkStats)stats
NS_SWIFT_NAME(rtcBridge(_:didUpdateNetworkStats:));
localDecoder:(NSString *)decoder
NS_SWIFT_NAME(rtcBridge(_:didUpdateNetworkStats:localDecoder:));
- (void)rtcBridge:(CrossDeskRTCBridge *)bridge
didUpdateRemoteVideoEncoder:(NSString *)encoder;
@end
/// Objective-C++ boundary around the native MiniRTC C API.
@@ -1434,6 +1434,7 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
action.type = ControlType::host_infomation;
action.i.platform = crossdesk::HostPlatform::IOS;
action.i.supports_privacy_screen = true;
action.i.supports_video_codec_status = true;
CopyCString(action.i.host_name, sizeof(action.i.host_name),
controller_name);
action.i.host_name_size = std::strlen(action.i.host_name);
@@ -1719,6 +1720,16 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
if (source == kControlStream) {
RemoteAction action{};
if (!action.from_json(std::string(data, size))) return;
if (action.type == ControlType::video_codec_status) {
NSString *encoder = [NSString stringWithUTF8String:action.vcs.encoder];
DispatchMain(^{
if (![self isControllerGenerationActive:generation]) return;
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:@selector(rtcBridge:didUpdateRemoteVideoEncoder:)])
[delegate rtcBridge:self didUpdateRemoteVideoEncoder:encoder ?: @""];
});
return;
}
if (action.type == ControlType::privacy_status) {
const auto message = action.to_json();
NSData *status = [NSData dataWithBytes:message.data() length:message.size()];
@@ -2034,13 +2045,17 @@ Params MakeParams(const RTCState &state, const std::string &user_id,
mode == TraversalMode::P2P ? CrossDeskTraversalModeDirect
: mode == TraversalMode::Relay ? CrossDeskTraversalModeRelay
: CrossDeskTraversalModeUnknown};
NSString *decoder = [[NSString alloc]
initWithBytes:stats->video_decoder
length:strnlen(stats->video_decoder, sizeof(stats->video_decoder))
encoding:NSUTF8StringEncoding];
DispatchMain(^{
// Disconnect/reconnect can happen while this report waits on the UI queue.
if (![self isControllerGenerationActive:generation]) return;
id<CrossDeskRTCBridgeDelegate> delegate = self.delegate;
if ([delegate respondsToSelector:
@selector(rtcBridge:didUpdateNetworkStats:)]) {
[delegate rtcBridge:self didUpdateNetworkStats:snapshot];
@selector(rtcBridge:didUpdateNetworkStats:localDecoder:)]) {
[delegate rtcBridge:self didUpdateNetworkStats:snapshot localDecoder:decoder ?: @""];
}
});
}
@@ -16,6 +16,7 @@ struct RemoteNetworkReport {
var mode: Mode = .unknown
var srtpActive = false
var rttMilliseconds: Double = -1
var localVideoDecoder = ""
}
struct RemoteNetworkSnapshot {
@@ -23,6 +24,8 @@ struct RemoteNetworkSnapshot {
var framesPerSecond = 0
var videoLatencyMilliseconds: Double?
var rttMilliseconds: Double?
var remoteVideoEncoder = ""
var localVideoDecoder = ""
var connectionMode: String {
switch report?.mode {
@@ -66,6 +69,11 @@ struct RemoteNetworkStatistics {
private var frames: [(time: TimeInterval, latency: Double?)] = []
private var displayedSnapshot = RemoteNetworkSnapshot()
private var displayTime: TimeInterval?
private var remoteVideoEncoder = ""
mutating func receiveRemoteVideoEncoder(_ encoder: String) {
remoteVideoEncoder = encoder
}
mutating func receive(_ report: RemoteNetworkReport, at now: TimeInterval) {
self.report = report
@@ -117,7 +125,9 @@ struct RemoteNetworkStatistics {
report: now - reportTime < 3 ? report : nil,
framesPerSecond: recentFrames.count,
videoLatencyMilliseconds: delays.isEmpty ? nil : delays.reduce(0, +) / Double(delays.count),
rttMilliseconds: now - rttTime < 3 ? averageRTT : nil
rttMilliseconds: now - rttTime < 3 ? averageRTT : nil,
remoteVideoEncoder: remoteVideoEncoder,
localVideoDecoder: report?.localVideoDecoder ?? ""
)
}
}
@@ -1473,7 +1473,14 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
}
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didUpdateNetworkStats stats: CrossDeskNetworkStats) {
didUpdateRemoteVideoEncoder encoder: String) {
guard hasNetworkConsent, isConnected else { return }
networkStatistics.receiveRemoteVideoEncoder(encoder)
}
func rtcBridge(_ bridge: CrossDeskRTCBridge,
didUpdateNetworkStats stats: CrossDeskNetworkStats,
localDecoder: String) {
guard hasNetworkConsent, isConnected else { return }
func traffic(_ value: CrossDeskTrafficStats) -> RemoteTrafficStatistics {
RemoteTrafficStatistics(inboundBitrate: value.inboundBitrate,
@@ -1489,7 +1496,8 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
networkStatistics.receive(RemoteNetworkReport(
video: traffic(stats.video), audio: traffic(stats.audio),
data: traffic(stats.data), total: traffic(stats.total), mode: mode,
srtpActive: stats.srtpActive.boolValue, rttMilliseconds: stats.rttMilliseconds
srtpActive: stats.srtpActive.boolValue, rttMilliseconds: stats.rttMilliseconds,
localVideoDecoder: localDecoder
), at: ProcessInfo.processInfo.systemUptime)
}
@@ -1327,6 +1327,47 @@ private struct FloatingSessionMenu: View {
}
}
private struct ScrollingCodecName: View {
let value: String
@State private var textWidth: CGFloat = 0
@State private var startedAt = Date()
@ScaledMetric(relativeTo: .caption) private var lineHeight = 16.0
var body: some View {
GeometryReader { bounds in
TimelineView(.animation(minimumInterval: 1.0 / 30, paused: textWidth <= bounds.size.width)) { context in
Text(value)
.lineLimit(1)
.fixedSize()
.background {
GeometryReader { text in
Color.clear
.onAppear { textWidth = text.size.width }
.onChange(of: text.size.width) { textWidth = $0; startedAt = Date() }
}
}
.offset(x: offset(in: bounds.size.width, at: context.date))
.frame(width: bounds.size.width, height: bounds.size.height, alignment: .leading)
.clipped()
}
.onAppear { startedAt = Date() }
.onChange(of: value) { _ in startedAt = Date() }
.onChange(of: bounds.size.width) { _ in startedAt = Date() }
}
.frame(height: lineHeight)
.accessibilityElement(children: .ignore)
.accessibilityLabel(value)
}
private func offset(in width: CGFloat, at time: Date) -> CGFloat {
let distance = max(0, textWidth - width)
guard distance > 0 else { return width - textWidth }
// Pause for one second at each end, then repeat at 30 points/second.
let phase = max(0, time.timeIntervalSince(startedAt)).truncatingRemainder(dividingBy: Double(distance) / 30 + 2)
return -min(distance, CGFloat(max(0, phase - 1) * 30))
}
}
private struct FloatingNetworkStatistics: View {
let snapshot: RemoteNetworkSnapshot
let frameSize: CGSize
@@ -1366,6 +1407,8 @@ private struct FloatingNetworkStatistics: View {
}
}
.accessibilityElement(children: .combine)
codec("被控端编码器", value: snapshot.remoteVideoEncoder)
codec("控制端解码器", value: snapshot.localVideoDecoder)
}
.font(.caption.monospacedDigit())
}
@@ -1386,6 +1429,14 @@ private struct FloatingNetworkStatistics: View {
.accessibilityElement(children: .combine)
}
private func codec(_ title: String, value: String) -> some View {
HStack(spacing: 8) {
Text(title).foregroundStyle(.secondary).fixedSize()
ScrollingCodecName(value: value.isEmpty ? "—" : value)
}
.accessibilityElement(children: .combine)
}
private func trafficRow(_ title: String, statistics: RemoteTrafficStatistics?) -> some View {
trafficRow(title,
inbound: RemoteNetworkSnapshot.bitrate(statistics?.inboundBitrate),