From 4650e48ea39f2b775d8a6c543c5315ace35259b6 Mon Sep 17 00:00:00 2001 From: dijunkun Date: Sat, 24 Oct 2026 03:28:23 +0800 Subject: [PATCH] [feat] add configurable landscape entry for mobile sessions --- apps/android/README.md | 1 + .../crossdesk/mobile/BackgroundSession.java | 1 + .../cn/crossdesk/mobile/MainActivity.java | 33 ++++--- .../crossdesk/mobile/RemoteOrientation.java | 57 +++++++++++ apps/harmony/README.md | 2 + .../src/main/ets/mobile/MobileController.ets | 5 +- .../src/main/ets/mobile/MobilePlatform.ets | 71 +++++++++++--- .../entry/src/main/ets/pages/Mobile.ets | 9 ++ apps/ios/CrossDeskMobile/ContentView.swift | 7 +- .../CrossDeskMobile/CrossDeskMobileApp.swift | 96 +++++++++++++++---- .../CrossDeskMobile/RemoteSessionModel.swift | 10 +- apps/ios/README.md | 9 ++ 12 files changed, 257 insertions(+), 44 deletions(-) create mode 100644 apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteOrientation.java diff --git a/apps/android/README.md b/apps/android/README.md index 64c249e2..7c78d44f 100644 --- a/apps/android/README.md +++ b/apps/android/README.md @@ -9,6 +9,7 @@ - 48 kHz 单声道 PCM 播放,支持静音,音频队列有容量限制。 - 相对鼠标(触控板)和绝对位置模式;单指点击、绝对模式按住拖动、长按右键,以及外接键盘、鼠标。画面手势与 iOS 一致:双指可在整个触控区域(包括画面外黑边)捏合,以触点为中心缩放(1–10 倍),放大后双指拖动画面,原始比例下双指上下滑动控制滚轮。相对鼠标移到可见画面的上、下边缘后继续向外移动,会反向上下平移画面,不触发左右平移;未放大时和按住虚拟鼠标键拖动时同样生效,停止移动即停止平移,抬手保留位置。移动到极限时,画面可见部分至少占当前显示区域高度的 20%,与缩放倍数无关;整幅画面高度不足该比例时完整保留,范围计入上下黑边;缩放结束及缩回 1 倍时保留平移位置,仅在超过可见边界时限制位置;切换显示器恢复居中。 - 横竖屏等比例显示、远端多显示器切换。与 iOS 一致的画面质量(低/中/高)、采集帧率(30/60 fps)、画面偏好;支持能力检测、电脑端确认、失败/5 秒超时回退及连续操作的应答排序。 +- “设置 → 屏幕方向 → 连接后自动横屏”默认开启并保存在本机。手机进入会话后先保持横屏,检测到手机实际横过来后才恢复跟随设备方向;竖持或平放不会立即切回竖屏。关闭后沿用自动旋转,平板与多窗口不受此选项影响。退出会话恢复首页方向,返回同一后台会话不重复锁定已解锁的方向。 - 根据常见移动设备解码能力和屏幕尺寸设置起始参数:显示长边不超过 1280 像素时默认低画质(最高 720p)/30 fps,其余默认中画质(最高 1080p)/30 fps。用户保存的画面偏好不变,仍可手动选择高画质或 60 fps。 - 网络状态按视频、音频、数据、合计展示收发速率和丢包率,并显示实际提交帧率、分辨率、画面延时、平滑 RTT、P2P/TURN 和实际 SRTP 状态。每秒刷新,过期或无效测量显示“—”。 - 可拖动的悬浮控制球、三列控制菜单;默认使用系统输入法,保留候选词、纠错和听写,确认文字后直接输入远端。电脑键盘固定底部,分“字母数字 / 符号功能”两页,共用末两行布局,按系统键盘高度适配横竖屏;统一切换、收起按钮和 Android 键盘触感反馈。 diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/BackgroundSession.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/BackgroundSession.java index defc0106..3d1c11e5 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/BackgroundSession.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/BackgroundSession.java @@ -19,6 +19,7 @@ final class BackgroundSession implements NativeSession.Listener { RemoteVideoSettings videoSettings; int signalState, selectedDisplay, width, height; boolean muted, remoteServiceAvailable, background, ended; + boolean waitingForLandscape; File receivedFile; BackgroundSession(Context context, NativeSession session, String host, int port, String remote) { diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java index 88b12544..36bca647 100644 --- a/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/MainActivity.java @@ -72,6 +72,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen private int videoWidth,videoHeight; private final RemotePictureInPicture pictureInPicture=new RemotePictureInPicture(); private RemoteVideoView video; + private RemoteOrientation remoteOrientation; private SessionControls controls; private Dialog passwordDialog, progressDialog, privacyDialog; private AlertDialog disconnectDialog; @@ -95,7 +96,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen private Runnable backAction; @Override public void onCreate(Bundle state){ - super.onCreate(state);ui=new MobileUi(this);history=new RecentConnections(this); + super.onCreate(state);ui=new MobileUi(this);history=new RecentConnections(this);remoteOrientation=new RemoteOrientation(this); pageHost=new PageHost(this);setContentView(pageHost); preferences=getSharedPreferences("settings",MODE_PRIVATE);host=preferences.getString("host","api.crossdesk.cn");port=preferences.getInt("port",9099); recentPresence.sender=ids->{if(signaling!=null&&!signaling.isClosed())signaling.presenceRequest(ids);}; @@ -178,14 +179,18 @@ public final class MainActivity extends Activity implements NativeSession.Listen TextView chevron=text(" ›",23,false);chevron.setTextColor(0xFFB7B7BD);item.addView(chevron);item.setContentDescription(title+(detail.isEmpty()?"":","+detail));item.setClickable(true);item.setFocusable(true);item.setOnClickListener(v->action.run());parent.addView(item); return item; } - @android.annotation.SuppressLint("SourceLockedOrientationActivity") // Phones keep the portrait home; tablets and multi-window follow user rotation settings. - private void updateHomeOrientation(){ + private boolean isTablet(){ boolean tablet=getResources().getConfiguration().smallestScreenWidthDp>=600; if(android.os.Build.VERSION.SDK_INT>=30){ android.graphics.Rect bounds=getWindowManager().getMaximumWindowMetrics().getBounds(); tablet|=Math.min(bounds.width(),bounds.height())>=dp(600); } - int orientation=tablet||isInMultiWindowMode()?ActivityInfo.SCREEN_ORIENTATION_FULL_USER:ActivityInfo.SCREEN_ORIENTATION_PORTRAIT; + return tablet; + } + @android.annotation.SuppressLint("SourceLockedOrientationActivity") // Phones keep the portrait home; tablets and multi-window follow user rotation settings. + private void updateHomeOrientation(){ + remoteOrientation.end(); + int orientation=isTablet()||isInMultiWindowMode()?ActivityInfo.SCREEN_ORIENTATION_FULL_USER:ActivityInfo.SCREEN_ORIENTATION_PORTRAIT; if(getRequestedOrientation()!=orientation)setRequestedOrientation(orientation); } private void home(String message){ @@ -321,8 +326,8 @@ public final class MainActivity extends Activity implements NativeSession.Listen ProgressBar spinner=new ProgressBar(this);spinner.setIndeterminateTintList(ColorStateList.valueOf(BLUE));body.addView(spinner,ui.size(34,34));ui.gap(body,14);TextView title=text("正在连接",20,true);title.setGravity(Gravity.CENTER);body.addView(title);ui.gap(body,6);status=text("正在连接信令服务器…",14,false);status.setGravity(Gravity.CENTER);status.setTextColor(SECONDARY);status.setMinHeight(dp(36));body.addView(status);card.addView(body,ui.size(-1,-2));ui.divider(card);card.addView(ui.action("取消连接",RED,()->end("")),ui.size(-1,46)); dialog.setContentView(card);dialog.setCanceledOnTouchOutside(false);dialog.setOnCancelListener(d->end(""));Window w=dialog.getWindow();if(w!=null){w.setBackgroundDrawableResource(android.R.color.transparent);w.setDimAmount(.08f);}dialog.show();if(w!=null)w.setLayout(dp(292),-2); } - private void sessionScreen(){ - installRoot("session",true);setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR);video=new RemoteVideoView(this,session);video.setRelative(preferences.getBoolean("relativeMouse",true));canvas.addView(video,0,new FrameLayout.LayoutParams(-1,-1)); + private void sessionScreen(boolean waitForLandscape){ + installRoot("session",true);remoteOrientation.begin(waitForLandscape&&!isTablet()&&!isInMultiWindowMode());video=new RemoteVideoView(this,session);video.setRelative(preferences.getBoolean("relativeMouse",true));canvas.addView(video,0,new FrameLayout.LayoutParams(-1,-1)); controls=new SessionControls(this,ui,new SessionControls.Actions(){ public void disconnect(){confirmDisconnect();}public void display(){selectDisplay();}public void clipboard(){MainActivity.this.clipboard();} public void sendFile(){chooseFile();}public void saveFile(){saveReceivedFile();}public void secureAttention(){MainActivity.this.secureAttention();} @@ -343,6 +348,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen } private void confirmDisconnect(){disconnectDialog=new AlertDialog.Builder(this).setTitle("断开远程连接?").setMessage("断开后将返回首页").setNegativeButton("取消",null).setPositiveButton("断开连接",(d,w)->end("")).show();} private void end(String reason){ + remoteOrientation.end(); if(backgroundSession!=null){BackgroundSession retained=backgroundSession;backgroundSession=null;retained.detach(this);retained.finish(reason);} boolean closeWindow=isInPictureInPictureMode(); pictureInPicture.disconnect();updatePictureInPicture(); @@ -362,6 +368,9 @@ public final class MainActivity extends Activity implements NativeSession.Listen mouse.addView(segments(new String[]{"相对位置","绝对位置"},relative?0:1,true,index->preferences.edit().putBoolean("relativeMouse",index==0).apply())); LinearLayout picture=section(content,"画面偏好",MobileUi.SETTINGS_CARD_RADIUS);picture.setPadding(dp(12),dp(12),dp(12),dp(12));int preference=Math.max(0,Math.min(2,preferences.getInt("videoPreference",1))); picture.addView(segments(RemoteVideoSettings.PREFERENCES,preference,true,index->preferences.edit().putInt("videoPreference",index).apply()));ui.gap(content,28); + LinearLayout direction=section(content,"屏幕方向",MobileUi.SETTINGS_CARD_RADIUS);LinearLayout directionRow=row();directionRow.setPadding(dp(16),dp(10),dp(16),dp(10));directionRow.addView(text("连接后自动横屏",16,false),new LinearLayout.LayoutParams(0,-2,1)); + Switch autoLandscape=new Switch(this);autoLandscape.setContentDescription("连接后自动横屏");autoLandscape.setChecked(preferences.getBoolean("autoLandscape",true));autoLandscape.setOnCheckedChangeListener((button,checked)->preferences.edit().putBoolean("autoLandscape",checked).apply());directionRow.addView(autoLandscape);direction.addView(directionRow); + footer(content,"手机连接后先进入横屏,将手机横过来后恢复跟随设备方向。关闭后始终跟随设备方向。平板和多窗口不受影响。"); LinearLayout server=section(content,"",MobileUi.SETTINGS_CARD_RADIUS);LinearLayout fields=column();fields.setVisibility(View.GONE);boolean custom=!host.equals("api.crossdesk.cn")||port!=9099; link(server,"服务器",custom?"自定义":"默认",()->fields.setVisibility(fields.getVisibility()==View.VISIBLE?View.GONE:View.VISIBLE));server.addView(fields); LinearLayout toggleRow=row();toggleRow.setPadding(dp(16),dp(8),dp(16),dp(8));toggleRow.addView(text("使用自定义服务器",16,false),new LinearLayout.LayoutParams(0,-2,1));Switch toggle=new Switch(this);toggle.setContentDescription("使用自定义服务器");toggle.setChecked(custom);toggleRow.addView(toggle);fields.addView(toggleRow); @@ -593,7 +602,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen // Transport readiness can be reported again after ICE changes its selected path. // The first success consumes pendingPassword; a duplicate must not save it again. if(session==null||ready)return;ready=true;if(progressDialog!=null){progressDialog.dismiss();progressDialog=null;} - try{history.save(serverKey(),connectedRemote,connectedRemote,hostPlatform,rememberPassword,pendingPassword);}catch(Exception error){toast("密码保存失败,本次连接仍可使用");}pendingPassword="";sessionScreen(); + try{history.save(serverKey(),connectedRemote,connectedRemote,hostPlatform,rememberPassword,pendingPassword);}catch(Exception error){toast("密码保存失败,本次连接仍可使用");}pendingPassword="";sessionScreen(preferences.getBoolean("autoLandscape",true)); if(!canUsePictureInPicture())prepareBackgroundSession(true); if(videoSettings.supported&&videoSettings.requestId==0)updateVideoSettings(videoSettings.selection); if(foreground&&preferences.getBoolean("networkConsent",false))remoteVersionCheck.connected(); @@ -670,6 +679,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen super.onConfigurationChanged(config); releaseKeys(); if(!page.equals("session"))updateHomeOrientation(); + else if(!isInPictureInPictureMode()&&(isTablet()||isInMultiWindowMode()))remoteOrientation.followDevice(); sizePasswordDialog(); if(privacyDialog instanceof PrivacyConsentDialog)((PrivacyConsentDialog)privacyDialog).updateSize(); if(controls!=null&&!pictureInPictureUi)controls.post(controls::constrainPanels); @@ -688,6 +698,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen retained.muted=muted;retained.selectedDisplay=selectedDisplay;retained.remoteServiceAvailable=remoteServiceAvailable; retained.clipboard=remoteClipboard;retained.videoSettings=videoSettings;retained.receivedFile=receivedFile; retained.width=videoWidth;retained.height=videoHeight; + retained.waitingForLandscape=remoteOrientation.waitingForLandscape(); } private boolean prepareBackgroundSession(boolean inform){ if(!ready||session==null||session.isClosed()||isFinishing()||!hasNetworkConsent())return false; @@ -723,7 +734,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen hostPlatform=retained.platform;displays=retained.displays;muted=retained.muted;selectedDisplay=retained.selectedDisplay; remoteServiceAvailable=retained.remoteServiceAvailable;remoteClipboard=retained.clipboard;receivedFile=retained.receivedFile; videoSettings=retained.videoSettings;ready=true;retained.attach(this); - if(rebuild)sessionScreen(); + if(rebuild)sessionScreen(retained.waitingForLandscape); if(retained.width>0&&retained.height>0)videoSize(retained.width,retained.height); if(controls!=null){controls.setMuted(muted);controls.videoSettings(videoSettings);controls.fileStatus(retained.fileStatus,receivedFile!=null);} retained.setBackground(false);signaling(signalState,localIdentity); @@ -771,9 +782,9 @@ public final class MainActivity extends Activity implements NativeSession.Listen super.onPictureInPictureUiStateChanged(state); if(android.os.Build.VERSION.SDK_INT>=35&&state.isTransitioningToPip())setPictureInPictureUi(true); } - @Override protected void onResume(){super.onResume();if(!isInPictureInPictureMode())enteredPictureInPicture=false;setPictureInPictureUi(isInPictureInPictureMode());updatePictureInPicture();if(ready&&!canUsePictureInPicture())prepareBackgroundSession(true);if(backgroundSession!=null)requestBackgroundNotifications();} + @Override protected void onResume(){super.onResume();remoteOrientation.resume();if(!isInPictureInPictureMode())enteredPictureInPicture=false;setPictureInPictureUi(isInPictureInPictureMode());updatePictureInPicture();if(ready&&!canUsePictureInPicture())prepareBackgroundSession(true);if(backgroundSession!=null)requestBackgroundNotifications();} // onStart marks a new visit; focus/resume callbacks must not reopen a refused notice. - @Override protected void onPause(){releaseKeys();if(controls!=null)controls.cancelMouseInput();if(video!=null)video.releaseMouse();super.onPause();} + @Override protected void onPause(){remoteOrientation.pause();releaseKeys();if(controls!=null)controls.cancelMouseInput();if(video!=null)video.releaseMouse();super.onPause();} @Override protected void onStart(){super.onStart();foreground=true;restoreBackgroundSession();appUpdates.setEnabled(preferences.getBoolean("networkConsent",false));if(!preferences.getBoolean("networkConsent",false))consent(this::renderSignaling);ensureSignaling();showConnectionMessage();} @Override protected void onStop(){if(controls!=null)controls.closeKeyboard();foreground=false;appUpdates.setEnabled(false);if(privacyDialog!=null)privacyDialog.dismiss();pageHost.finish(); if(connectionDialog!=null)connectionDialog.dismiss(); @@ -791,5 +802,5 @@ public final class MainActivity extends Activity implements NativeSession.Listen boolean retained=!isFinishing()&&backgroundSession!=null&&!backgroundSession.ended&&RemoteSessionService.current()==backgroundSession; if(backgroundSession!=null)backgroundSession.detach(this); if(!retained){if(session!=null)session.close();stopSignaling();}else{mainHandler.removeCallbacks(presenceTick);session=signaling=null;} - super.onDestroy();} + remoteOrientation.end();super.onDestroy();} } diff --git a/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteOrientation.java b/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteOrientation.java new file mode 100644 index 00000000..c6b45a82 --- /dev/null +++ b/apps/android/app/src/main/java/cn/crossdesk/mobile/RemoteOrientation.java @@ -0,0 +1,57 @@ +package cn.crossdesk.mobile; + +import android.app.Activity; +import android.content.pm.ActivityInfo; +import android.view.OrientationEventListener; + +/** Holds the initial landscape until the phone itself has been turned sideways. */ +final class RemoteOrientation { + private final Activity activity; + private final OrientationEventListener sensor; + private boolean waitingForLandscape, resumed; + + RemoteOrientation(Activity activity) { + this.activity = activity; + sensor = new OrientationEventListener(activity) { + @Override public void onOrientationChanged(int degrees) { + if (!resumed || !waitingForLandscape || activity.isInPictureInPictureMode()) return; + // Use physical orientation, never the window rotated by our own request. + // Ignore flat/unknown poses and keep a margin away from the diagonal. + if ((degrees >= 60 && degrees <= 120) || (degrees >= 240 && degrees <= 300)) { + followDevice(); + } + } + }; + } + + void begin(boolean autoLandscape) { + sensor.disable(); + waitingForLandscape = autoLandscape && sensor.canDetectOrientation(); + apply(); + if (resumed && waitingForLandscape) sensor.enable(); + } + + boolean waitingForLandscape() { return waitingForLandscape; } + + void resume() { + resumed = true; + if (waitingForLandscape && !activity.isInPictureInPictureMode()) sensor.enable(); + } + + void pause() { resumed = false; sensor.disable(); } + + void end() { waitingForLandscape = false; sensor.disable(); } + + void followDevice() { + waitingForLandscape = false; + sensor.disable(); + apply(); + } + + private void apply() { + int orientation = waitingForLandscape + ? ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE + : ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR; + if (activity.getRequestedOrientation() != orientation) activity.setRequestedOrientation(orientation); + } +} diff --git a/apps/harmony/README.md b/apps/harmony/README.md index 3c68703b..b8117f93 100644 --- a/apps/harmony/README.md +++ b/apps/harmony/README.md @@ -9,6 +9,8 @@ 两者仍为开发版。已完成的模拟器验证及未完成的真机验收见各端说明。 +手机控制端的“设置 → 屏幕方向 → 连接后自动横屏”默认开启并保存在本机。连接后先保持横屏,检测到手机实际横过来后恢复跟随设备方向;竖持、平放或斜持不会解除初始横屏。关闭后沿用自动旋转,平板和多窗口不受此选项影响。退出会话恢复首页方向,返回同一后台会话不重复锁定已解锁的方向;重力传感器不可用时沿用自动旋转。 + ## 构建 在仓库根目录执行,当前构建脚本面向 macOS: diff --git a/apps/harmony/entry/src/main/ets/mobile/MobileController.ets b/apps/harmony/entry/src/main/ets/mobile/MobileController.ets index 9891c6e3..48580a03 100644 --- a/apps/harmony/entry/src/main/ets/mobile/MobileController.ets +++ b/apps/harmony/entry/src/main/ets/mobile/MobileController.ets @@ -50,6 +50,7 @@ export class MobileState { muted: boolean = false; previews: boolean = false; videoPreference: number = 1; + autoLandscape: boolean = true; version: string = '0.1.0'; update: string = ''; updateStatus: string = ''; @@ -122,6 +123,7 @@ export class MobileController { this.state.consent = this.store.flag('consent'); this.state.server = this.store.text('server', 'api.crossdesk.cn'); this.state.port = this.store.text('port', '9099'); this.state.remote = this.store.text('lastRemote'); this.state.relative = this.store.flag('relative', true); this.state.videoPreference = this.store.number('videoPreference', 1); + this.state.autoLandscape = this.store.flag('autoLandscape', true); this.state.previews = this.store.flag('previews'); try { this.state.version = bundleManager.getBundleInfoForSelfSync(bundleManager.BundleFlag.GET_BUNDLE_INFO_DEFAULT).versionName; } catch {} try { @@ -280,6 +282,7 @@ export class MobileController { this.settingsTimeout = setTimeout(() => { this.video.expire(id); this.syncVideo(); }, 5000); this.syncVideo(); } setPreference(value: number): void { this.store.put('videoPreference', value); this.state.videoPreference = value; } + setAutoLandscape(value: boolean): void { this.store.put('autoLandscape', value); this.state.autoLandscape = value; } setPreviews(value: boolean): void { this.store.put('previews', value); this.state.previews = value; if (!value) { this.send({ op: 'cancelPreview' }); this.previewEligible = false; this.store.clearPreviews(); this.history(); } @@ -427,7 +430,7 @@ export class MobileController { this.history(); } }).catch(() => this.toast('密码保存失败,本次连接仍可使用')); - this.platform.connected(); + this.platform.connected(this.state.autoLandscape); if (this.video.supported && this.video.requestId === 0) this.updateVideo(0, this.video.selection.quality); if (this.receivedHostInfo) this.checkRemote(this.remoteVersion); } diff --git a/apps/harmony/entry/src/main/ets/mobile/MobilePlatform.ets b/apps/harmony/entry/src/main/ets/mobile/MobilePlatform.ets index fe59f879..d9783579 100644 --- a/apps/harmony/entry/src/main/ets/mobile/MobilePlatform.ets +++ b/apps/harmony/entry/src/main/ets/mobile/MobilePlatform.ets @@ -4,6 +4,7 @@ import { PiPWindow, window, display } from '@kit.ArkUI'; import { backgroundTaskManager } from '@kit.BackgroundTasksKit'; import { picker, fileIo as fs, fileUri } from '@kit.CoreFileKit'; import { http } from '@kit.NetworkKit'; +import { sensor } from '@kit.SensorServiceKit'; import { isAnnouncementURL } from './AnnouncementText'; export class MobileLifecycle { @@ -21,6 +22,19 @@ export class MobilePlatform { private generation: number = 0; private backgroundActive: boolean = false; private active: boolean = false; + private waitingForLandscape: boolean = false; + private observingOrientation: boolean = false; + private orientationRevision: number = 0; + private orientationQueue: Promise = Promise.resolve(); + private gravityChanged: (data: sensor.GravityResponse) => void = (data: sensor.GravityResponse) => { + if (!this.active || !this.foreground || !this.waitingForLandscape || !this.observingOrientation) return; + // Gravity is in device coordinates, independent of programmatic window rotation. + // Reject flat and diagonal poses before handing rotation back to the system. + const x = Math.abs(data.x), y = Math.abs(data.y), z = Math.abs(data.z); + if (x >= 6 && x >= y * 1.7 && x >= z) { + this.waitingForLandscape = false; this.stopOrientationSensor(); this.orientation(true); + } + }; private foreground: boolean = true; choosing: boolean = false; pipActive: boolean = false; @@ -37,7 +51,9 @@ export class MobilePlatform { this.context = context; this.ended = ended; this.release = release; this.feedback = feedback; try { backgroundTaskManager.on('continuousTaskCancel', this.backgroundCancelled); } catch {} } - async connected(): Promise { + async connected(autoLandscape: boolean = true): Promise { + if (this.active) return; + this.waitingForLandscape = autoLandscape && deviceInfo.deviceType === 'phone'; this.active = true; const generation = ++this.generation; this.muted = false; this.orientation(true); this.backgroundQueue = this.backgroundQueue.then(async () => { @@ -90,7 +106,7 @@ export class MobilePlatform { } videoSize(width: number, height: number): void { try { this.pip?.updateContentSize(width, height); } catch { /* Not supported on this device. */ } } async background(): Promise { - this.foreground = false; this.release(); this.cancelRequests(); + this.foreground = false; this.stopOrientationSensor(); this.release(); this.cancelRequests(); this.onVisibility(this.pipActive); if (!this.active) return; const generation = this.generation; @@ -109,6 +125,7 @@ export class MobilePlatform { } dispose(): void { this.disconnect(); try { backgroundTaskManager.off('continuousTaskCancel', this.backgroundCancelled); } catch {} } disconnect(): void { + this.waitingForLandscape = false; this.stopOrientationSensor(); this.active = false; this.choosing = false; this.generation++; this.cancelRequests(); const pip = this.pip; this.pip = undefined; this.pipActive = false; if (pip) { try { pip.setAutoStartEnabled(false); pip.stopPiP().catch(() => {}); } catch { /* Already stopped. */ } } @@ -117,18 +134,46 @@ export class MobilePlatform { }); this.orientation(false); } - private async orientation(session: boolean): Promise { - const generation = this.generation; + private stopOrientationSensor(): void { + if (!this.observingOrientation) return; + this.observingOrientation = false; + try { sensor.off(sensor.SensorId.GRAVITY, this.gravityChanged); } catch {} + } + private startOrientationSensor(): void { + if (!this.waitingForLandscape || !this.foreground || this.observingOrientation) return; try { - const win = await window.getLastWindow(this.context); - if (generation !== this.generation) return; - await win.setWindowKeepScreenOn(session); - if (generation !== this.generation) return; - // Tablets and resizable windows follow the user orientation in both views. - const phone = deviceInfo.deviceType === 'phone'; - const narrow = win.getWindowProperties().windowRect.width / display.getDefaultDisplaySync().densityPixels < 600; - await win.setPreferredOrientation(!session && phone && narrow ? window.Orientation.PORTRAIT : window.Orientation.AUTO_ROTATION); - } catch { /* Window may already be going away. */ } + sensor.getSingleSensorSync(sensor.SensorId.GRAVITY); + sensor.on(sensor.SensorId.GRAVITY, this.gravityChanged, { interval: 100000000 }); + this.observingOrientation = true; + } catch { + // A phone without a usable sensor keeps the previous system-following policy. + this.waitingForLandscape = false; + } + } + private orientation(session: boolean): void { + const generation = this.generation; + const revision = ++this.orientationRevision; + // Serialize window requests so an earlier landscape request cannot win after + // the sensor has released it or the session has disconnected. + this.orientationQueue = this.orientationQueue.then(async () => { + if (generation !== this.generation || revision !== this.orientationRevision) return; + try { + const win = await window.getLastWindow(this.context); + if (generation !== this.generation || revision !== this.orientationRevision) return; + await win.setWindowKeepScreenOn(session); + if (generation !== this.generation || revision !== this.orientationRevision) return; + // Tablets and resizable windows follow the user orientation in both views. + const phone = deviceInfo.deviceType === 'phone'; + const rect = win.getWindowProperties().windowRect; + const narrow = Math.min(rect.width, rect.height) / display.getDefaultDisplaySync().densityPixels < 600; + const fullScreen = win.getWindowStatus() === window.WindowStatusType.FULL_SCREEN; + if (!phone || !narrow || !fullScreen) { this.waitingForLandscape = false; this.stopOrientationSensor(); } + if (session) this.startOrientationSensor(); + const orientation = !session && phone && narrow && fullScreen ? window.Orientation.PORTRAIT : + session && this.waitingForLandscape ? window.Orientation.AUTO_ROTATION_LANDSCAPE : window.Orientation.AUTO_ROTATION; + await win.setPreferredOrientation(orientation); + } catch { /* Window may already be going away. */ } + }); } private setChoosing(value: boolean): void { this.choosing = value; this.release(); diff --git a/apps/harmony/entry/src/main/ets/pages/Mobile.ets b/apps/harmony/entry/src/main/ets/pages/Mobile.ets index 21eefa74..56426ed1 100644 --- a/apps/harmony/entry/src/main/ets/pages/Mobile.ets +++ b/apps/harmony/entry/src/main/ets/pages/Mobile.ets @@ -267,6 +267,15 @@ struct Mobile { MobileSegment({ labels: ['帧率优先', '画质优先', '平衡'], selection: this.state.videoPreference, changed: (i: number) => this.controller?.setPreference(i) }) }.padding(12).width('100%').backgroundColor(Color.White).borderRadius(26).border({ width: 0.5, color: MobileTheme.line }) + this.SectionTitle('屏幕方向') + Row() { + Text('连接后自动横屏').fontSize(16).fontColor(MobileTheme.ink).layoutWeight(1) + Toggle({ type: ToggleType.Switch, isOn: this.state.autoLandscape }).selectedColor(MobileTheme.green) + .onChange((value: boolean) => this.controller?.setAutoLandscape(value)).accessibilityText('连接后自动横屏') + }.padding({ left: 16, right: 16, top: 10, bottom: 10 }).width('100%').backgroundColor(Color.White) + .borderRadius(26).border({ width: 0.5, color: MobileTheme.line }) + Text('手机连接后先进入横屏,将手机横过来后恢复跟随设备方向。关闭后始终跟随设备方向。平板和多窗口不受影响。') + .fontSize(12).fontColor(MobileTheme.secondary).width('100%').padding({ left: 16, right: 16, top: 8, bottom: 12 }) Column() { this.SettingRow('服务器', this.customServer ? '自定义' : '默认', () => this.serverExpanded = !this.serverExpanded) if (this.serverExpanded) { diff --git a/apps/ios/CrossDeskMobile/ContentView.swift b/apps/ios/CrossDeskMobile/ContentView.swift index 875b54e3..f5f2582a 100644 --- a/apps/ios/CrossDeskMobile/ContentView.swift +++ b/apps/ios/CrossDeskMobile/ContentView.swift @@ -75,7 +75,7 @@ struct ContentView: View { } .onAppear { if !session.sessionVisible { - AppOrientation.update(to: .portrait) + AppOrientation.endSession() } if scenePhase == .active { session.applicationDidBecomeActive() @@ -840,6 +840,11 @@ private struct ServerSettingsView: View { } .pickerStyle(.segmented) } + Section { + Toggle("连接后自动横屏", isOn: $session.autoLandscape) + } header: { + Text("屏幕方向") + } Section { DisclosureGroup(isExpanded: $serverConfigurationExpanded) { Toggle("使用自定义服务器", isOn: $usesCustomServer) diff --git a/apps/ios/CrossDeskMobile/CrossDeskMobileApp.swift b/apps/ios/CrossDeskMobile/CrossDeskMobileApp.swift index bf2d528e..d8ce253d 100644 --- a/apps/ios/CrossDeskMobile/CrossDeskMobileApp.swift +++ b/apps/ios/CrossDeskMobile/CrossDeskMobileApp.swift @@ -12,24 +12,88 @@ final class CrossDeskAppDelegate: NSObject, UIApplicationDelegate { } } +@MainActor enum AppOrientation { - static func update(to orientations: UIInterfaceOrientationMask) { - DispatchQueue.main.async { - // iPad windows can rotate and resize independently of the session. - // Keep all orientations available and let the window manager choose - // the geometry; the phone still locks the home screen to portrait. - guard UIDevice.current.userInterfaceIdiom != .pad else { return } - CrossDeskAppDelegate.supportedOrientations = orientations + nonisolated static let autoLandscapeKey = "crossdesk.mobile.auto-landscape" + private static var sessionActive = false + private static var waitingForLandscape = false + private static var deviceObserver: NSObjectProtocol? - for case let scene as UIWindowScene in UIApplication.shared.connectedScenes { - scene.windows.forEach { - $0.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations() - } - scene.requestGeometryUpdate( - .iOS(interfaceOrientations: orientations) - ) { error in - NSLog("CrossDesk orientation update failed: %@", error.localizedDescription) - } + nonisolated static func beginSession(autoLandscape: Bool) { + DispatchQueue.main.async { beginSessionOnMain(autoLandscape: autoLandscape) } + } + + private static func beginSessionOnMain(autoLandscape: Bool) { + // Repeated transport-ready callbacks must not lock an existing session again. + guard !sessionActive else { return } + sessionActive = true + waitingForLandscape = autoLandscape && UIDevice.current.userInterfaceIdiom == .phone + if waitingForLandscape { + CrossDeskAppDelegate.supportedOrientations = .landscape + resumeObserving() + } else { + apply(to: .allButUpsideDown) + } + } + + nonisolated static func endSession() { + DispatchQueue.main.async { + sessionActive = false + waitingForLandscape = false + stopObserving() + apply(to: .portrait) + } + } + + nonisolated static func resume() { + DispatchQueue.main.async { resumeObserving() } + } + + private static func resumeObserving() { + guard sessionActive, waitingForLandscape, deviceObserver == nil, + UIApplication.shared.applicationState == .active else { return } + apply(to: .landscape) + deviceObserver = NotificationCenter.default.addObserver( + forName: UIDevice.orientationDidChangeNotification, object: nil, queue: .main + ) { _ in + MainActor.assumeIsolated { deviceDidRotate() } + } + UIDevice.current.beginGeneratingDeviceOrientationNotifications() + deviceDidRotate() + } + + nonisolated static func suspend() { + DispatchQueue.main.async { stopObserving() } + } + + private static func stopObserving() { + guard let observer = deviceObserver else { return } + NotificationCenter.default.removeObserver(observer) + deviceObserver = nil + UIDevice.current.endGeneratingDeviceOrientationNotifications() + } + + private static func deviceDidRotate() { + // UIDevice reports physical posture; window geometry also changes when + // we request landscape and must never release the initial lock itself. + guard sessionActive, waitingForLandscape, deviceObserver != nil, + UIApplication.shared.applicationState == .active, + UIDevice.current.orientation.isLandscape else { return } + waitingForLandscape = false + stopObserving() + apply(to: .allButUpsideDown) + } + + private static func apply(to orientations: UIInterfaceOrientationMask) { + // iPad keeps every orientation available, including in resizable windows. + guard UIDevice.current.userInterfaceIdiom != .pad else { return } + CrossDeskAppDelegate.supportedOrientations = orientations + for case let scene as UIWindowScene in UIApplication.shared.connectedScenes { + scene.windows.forEach { + $0.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations() + } + scene.requestGeometryUpdate(.iOS(interfaceOrientations: orientations)) { error in + NSLog("CrossDesk orientation update failed: %@", error.localizedDescription) } } } diff --git a/apps/ios/CrossDeskMobile/RemoteSessionModel.swift b/apps/ios/CrossDeskMobile/RemoteSessionModel.swift index ec1cad52..a88a8400 100644 --- a/apps/ios/CrossDeskMobile/RemoteSessionModel.swift +++ b/apps/ios/CrossDeskMobile/RemoteSessionModel.swift @@ -312,6 +312,9 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe @Published var videoAdaptationPolicy = VideoAdaptationPolicy.saved { didSet { videoAdaptationPolicy.save() } } + @Published var autoLandscape = UserDefaults.standard.object(forKey: AppOrientation.autoLandscapeKey) as? Bool ?? true { + didSet { UserDefaults.standard.set(autoLandscape, forKey: AppOrientation.autoLandscapeKey) } + } @Published private(set) var videoSettings = RemoteVideoSettingsState() @Published var remoteID = "" @Published var password = "" @@ -437,6 +440,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe func applicationDidBecomeActive() { appIsActive = true + AppOrientation.resume() pictureInPicture.didBecomeActive() appUpdates.setEnabled(hasNetworkConsent) privacyNotice.didBecomeActive(hasNetworkConsent: hasNetworkConsent) @@ -447,6 +451,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe func applicationDidEnterBackground() { appIsActive = false + AppOrientation.suspend() appUpdates.setEnabled(false) privacyNotice.didEnterBackground() suspendVideoForBackground() @@ -591,6 +596,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe } private func beginRemoteConnection(_ identifier: String) { + AppOrientation.endSession() networkStatistics = RemoteNetworkStatistics() resetRemoteHostInfo() cancelVideoRecovery() @@ -714,7 +720,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe selectedDisplay = 0 connectionStatus = "未连接" audioPlayer.endSession() - AppOrientation.update(to: .portrait) + AppOrientation.endSession() appUpdates.checkNow() } @@ -1191,7 +1197,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe recordSuccessfulConnectionIfNeeded() audioPlayer.setEnabled(audioEnabled) bridge.setAudioEnabled(audioEnabled) - AppOrientation.update(to: .allButUpsideDown) + AppOrientation.beginSession(autoLandscape: autoLandscape) case 2: connectionStatus = "正在收集 ICE 候选" case 3: finishConnection(withError: "网络连接已中断,请重新连接。") case 4: diff --git a/apps/ios/README.md b/apps/ios/README.md index 95504bbd..f0a8308d 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -203,6 +203,15 @@ the available space. Settings and reading pages are limited to 720 points. Resizing preserves the connection fields and active session. iPhone retains its portrait home screen and two-column recent-connection grid. +On iPhone, Settings → Screen orientation (屏幕方向) → Landscape after connecting +(连接后自动横屏) is enabled by default and saved locally. A new session starts in +landscape and keeps that orientation until the phone is physically held sideways; +it then resumes the existing device-following policy for the rest of the session. +Portrait, flat and unknown device poses do not release the initial landscape hold. +Turning the option off keeps the existing rotation behavior. Leaving the session +restores portrait; foregrounding the same session does not reapply a released hold. +iPad orientation and multitasking are unaffected. + ## System keyboard and remote text The session keyboard button opens the iPhone/iPad's selected system keyboard.