diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/NativeSession.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/NativeSession.java index 317d5e2a..0ecc9cfc 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/NativeSession.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/NativeSession.java @@ -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(() -> { diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteNetworkStatistics.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteNetworkStatistics.java index b447ecf7..a1fe6fb2 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteNetworkStatistics.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteNetworkStatistics.java @@ -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;i0&&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 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";} diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/SessionControls.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/SessionControls.java index bf3ca2e3..6375a2b3 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/SessionControls.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/SessionControls.java @@ -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=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=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=ui.dp(400)?4:3;} diff --git a/apps/android/native/rtc_bridge.cpp b/apps/android/native/rtc_bridge.cpp index ef2bf779..a0ebbc79 100644 --- a/apps/android/native/rtc_bridge.cpp +++ b/apps/android/native/rtc_bridge.cpp @@ -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)( diff --git a/apps/desktop/src/gui/ui/common.slint b/apps/desktop/src/gui/ui/common.slint index 761cfc3d..15733234 100644 --- a/apps/desktop/src/gui/ui/common.slint +++ b/apps/desktop/src/gui/ui/common.slint @@ -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 text; + in property text-color; + in property font-size: 11px; + in property font-weight: 400; + in property align-right: false; + private property distance: max(0px, label.preferred-width - root.width); + private property 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 corner-radius: UiRadius.menu; } diff --git a/apps/desktop/src/gui/ui/gallery_network_dialog.slint b/apps/desktop/src/gui/ui/gallery_network_dialog.slint index 0f98b7da..e819e0a4 100644 --- a/apps/desktop/src/gui/ui/gallery_network_dialog.slint +++ b/apps/desktop/src/gui/ui/gallery_network_dialog.slint @@ -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; } } } diff --git a/apps/desktop/src/gui/ui/stream_window.slint b/apps/desktop/src/gui/ui/stream_window.slint index 0987c500..c72e35d8 100644 --- a/apps/desktop/src/gui/ui/stream_window.slint +++ b/apps/desktop/src/gui/ui/stream_window.slint @@ -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; } } } diff --git a/apps/desktop/tests/slint_stream_render_test.cpp b/apps/desktop/tests/slint_stream_render_test.cpp index f82494b5..b22d0be6 100644 --- a/apps/desktop/tests/slint_stream_render_test.cpp +++ b/apps/desktop/tests/slint_stream_render_test.cpp @@ -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>(); stream->set_stats_rows(rows); std::vector 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) << ',' diff --git a/apps/desktop/tests/slint_ui_smoke_test.cpp b/apps/desktop/tests/slint_ui_smoke_test.cpp index 12063f94..5d2c8c8a 100644 --- a/apps/desktop/tests/slint_ui_smoke_test.cpp +++ b/apps/desktop/tests/slint_ui_smoke_test.cpp @@ -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{1280, 720})); + stream->show(); std::vector 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{"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{"Ctrl+Alt+Del"}) return false; + + stream->set_secure_attention_supported(true); + settle(); + click(90, 19); + click(90, 98); + return commands == std::vector{"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()) { diff --git a/apps/harmony/entry/src/main/ets/mobile/ControllerModels.ets b/apps/harmony/entry/src/main/ets/mobile/ControllerModels.ets index b9868661..42c2048b 100644 --- a/apps/harmony/entry/src/main/ets/mobile/ControllerModels.ets +++ b/apps/harmony/entry/src/main/ets/mobile/ControllerModels.ets @@ -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; diff --git a/apps/harmony/entry/src/main/ets/mobile/MobileController.ets b/apps/harmony/entry/src/main/ets/mobile/MobileController.ets index a01197c5..65591a8b 100644 --- a/apps/harmony/entry/src/main/ets/mobile/MobileController.ets +++ b/apps/harmony/entry/src/main/ets/mobile/MobileController.ets @@ -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; diff --git a/apps/harmony/entry/src/main/ets/mobile/MobileRemoteView.ets b/apps/harmony/entry/src/main/ets/mobile/MobileRemoteView.ets index 6a1140f7..954d7157 100644 --- a/apps/harmony/entry/src/main/ets/mobile/MobileRemoteView.ets +++ b/apps/harmony/entry/src/main/ets/mobile/MobileRemoteView.ets @@ -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) diff --git a/apps/harmony/native/mobile_session.cpp b/apps/harmony/native/mobile_session.cpp index 7252751d..3fc9c401 100644 --- a/apps/harmony/native/mobile_session.cpp +++ b/apps/harmony/native/mobile_session.cpp @@ -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(); diff --git a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h index b9020186..b8e19824 100644 --- a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h +++ b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.h @@ -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. diff --git a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm index b2c9f254..8165c25a 100644 --- a/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm +++ b/apps/ios/CrossDeskMobile/Bridge/CrossDeskRTCBridge.mm @@ -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 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 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 ?: @""]; } }); } diff --git a/apps/ios/CrossDeskMobile/RemoteNetworkStatistics.swift b/apps/ios/CrossDeskMobile/RemoteNetworkStatistics.swift index 1e958f7a..46f69562 100644 --- a/apps/ios/CrossDeskMobile/RemoteNetworkStatistics.swift +++ b/apps/ios/CrossDeskMobile/RemoteNetworkStatistics.swift @@ -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 ?? "" ) } } diff --git a/apps/ios/CrossDeskMobile/RemoteSessionModel.swift b/apps/ios/CrossDeskMobile/RemoteSessionModel.swift index aed2dc7d..066a5656 100644 --- a/apps/ios/CrossDeskMobile/RemoteSessionModel.swift +++ b/apps/ios/CrossDeskMobile/RemoteSessionModel.swift @@ -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) } diff --git a/apps/ios/CrossDeskMobile/RemoteSessionView.swift b/apps/ios/CrossDeskMobile/RemoteSessionView.swift index 47b1c2f3..f54acb30 100644 --- a/apps/ios/CrossDeskMobile/RemoteSessionView.swift +++ b/apps/ios/CrossDeskMobile/RemoteSessionView.swift @@ -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), diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 6ba48693..942fa384 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -123,7 +123,7 @@ sudo apt install "./crossdesk-virtual-desktop-xfce-linux-amd64-.deb" 桌面端可同步文本剪贴板。接收文件的位置可在“设置 → 常规 → 文件保存路径”调整。关闭主窗口会隐藏窗口并保持程序运行;需要完全退出时使用系统托盘或菜单栏中的退出入口。 -普通会话与桌面墙的网络状态均显示实际使用的视频编解码器名称,包括回退后的软件编解码器。名称统一采用“实现名称 (编码格式)”,名称首字母大写、技术缩写保留全大写,例如 `VideoToolbox (H.264)`、`NVENC (H.264)`、`NVDEC (H.264)`、`SVT-AV1 (AV1)`、`Dav1d (AV1)` 和 `LibAOM (AV1)`;Media Foundation 额外标注 `HW`(硬件)或 `SW`(软件)。被控端需要支持编码器状态上报;旧版本或尚未获取到的信息显示“—”。 +桌面端普通会话、桌面墙和移动端(Android、iOS、鸿蒙)的网络状态均显示“被控端编码器”和“控制端解码器”,使用实际的视频编解码器名称,包括回退后的软件编解码器。名称保持单行,超出可用宽度时自动横向滚动,完整显示时保持静止。名称统一采用“实现名称 (编码格式)”,名称首字母大写、技术缩写保留全大写,例如 `VideoToolbox (H.264)`、`NVENC (H.264)`、`NVDEC (H.264)`、`SVT-AV1 (AV1)`、`Dav1d (AV1)` 和 `LibAOM (AV1)`;Media Foundation 额外标注 `HW`(硬件)或 `SW`(软件)。被控端需要支持编码器状态上报;旧版本或尚未获取到的信息显示“—”。 ### 在会话中调整画面 diff --git a/libs/controller/include/mobile_controller_protocol.h b/libs/controller/include/mobile_controller_protocol.h index 9266680c..410bc667 100644 --- a/libs/controller/include/mobile_controller_protocol.h +++ b/libs/controller/include/mobile_controller_protocol.h @@ -10,6 +10,7 @@ #include #include #include +#include namespace crossdesk::mobile_controller { inline std::string PrivacyMessage(int flag, bool block_local_input) { @@ -38,6 +39,7 @@ inline nlohmann::json NetworkReport(const MiniRtcNetTrafficStats& stats, Travers return nlohmann::json{{"inbound", in.bitrate}, {"outbound", out.bitrate}, {"loss", in.loss_rate}}; }; return {{"rtt", stats.rtt_ms}, {"mode", mode}, {"srtp", stats.srtp_active}, + {"decoder", std::string(stats.video_decoder, strnlen(stats.video_decoder, sizeof(stats.video_decoder)))}, {"video", traffic(stats.video_inbound_stats, stats.video_outbound_stats)}, {"audio", traffic(stats.audio_inbound_stats, stats.audio_outbound_stats)}, {"data", traffic(stats.data_inbound_stats, stats.data_outbound_stats)},