mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-28 00:33:06 +08:00
[feat] add mobile codec status and scrolling codec names
This commit is contained in:
@@ -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;}
|
||||
|
||||
@@ -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)(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) << ','
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user