[fix] make mobile landscape locking opt-in

This commit is contained in:
dijunkun
2026-10-27 03:55:17 +08:00
parent 0e052e6d7a
commit a9e3178042
12 changed files with 57 additions and 154 deletions
+1 -1
View File
@@ -9,7 +9,7 @@
- 48 kHz 单声道 PCM 播放,支持静音,音频队列有容量限制。 - 48 kHz 单声道 PCM 播放,支持静音,音频队列有容量限制。
- 相对鼠标(触控板)和绝对位置模式;单指点击、绝对模式按住拖动、长按右键,以及外接键盘、鼠标。画面手势与 iOS 一致:双指可在整个触控区域(包括画面外黑边)捏合,以触点为中心缩放(1–10 倍),放大后双指拖动画面,原始比例下双指上下滑动控制滚轮。相对鼠标移到可见画面的上、下边缘后继续向外移动,会反向上下平移画面,不触发左右平移;未放大时和按住虚拟鼠标键拖动时同样生效,停止移动即停止平移,抬手保留位置。移动到极限时,画面可见部分至少占当前显示区域高度的 20%,与缩放倍数无关;整幅画面高度不足该比例时完整保留,范围计入上下黑边;缩放结束及缩回 1 倍时保留平移位置,仅在超过可见边界时限制位置;切换显示器恢复居中。 - 相对鼠标(触控板)和绝对位置模式;单指点击、绝对模式按住拖动、长按右键,以及外接键盘、鼠标。画面手势与 iOS 一致:双指可在整个触控区域(包括画面外黑边)捏合,以触点为中心缩放(1–10 倍),放大后双指拖动画面,原始比例下双指上下滑动控制滚轮。相对鼠标移到可见画面的上、下边缘后继续向外移动,会反向上下平移画面,不触发左右平移;未放大时和按住虚拟鼠标键拖动时同样生效,停止移动即停止平移,抬手保留位置。移动到极限时,画面可见部分至少占当前显示区域高度的 20%,与缩放倍数无关;整幅画面高度不足该比例时完整保留,范围计入上下黑边;缩放结束及缩回 1 倍时保留平移位置,仅在超过可见边界时限制位置;切换显示器恢复居中。
- 横竖屏等比例显示、远端多显示器切换。与 iOS 一致的画面质量(低/中/高)、采集帧率(30/60 fps)、画面偏好;支持能力检测、电脑端确认、失败/5 秒超时回退及连续操作的应答排序。 - 横竖屏等比例显示、远端多显示器切换。与 iOS 一致的画面质量(低/中/高)、采集帧率(30/60 fps)、画面偏好;支持能力检测、电脑端确认、失败/5 秒超时回退及连续操作的应答排序。
- “设置 → 屏幕方向 → 连接后自动横屏”默认开启并保存在本机。手机进入会话后先保持横屏,检测到手机实际横过来后才恢复跟随设备方向;竖持或平放不会立即切回竖屏。关闭后沿用自动旋转,平板与多窗口不受此选项影响。退出会话恢复首页方向,返回同一后台会话不重复锁定已解锁的方向。 - “设置 → 屏幕方向 → 连接建立后锁定横屏”默认关闭并保存在本机。开启后,手机连接时进入并保持横屏;关闭时跟随设备方向。平板和多窗口不受此选项影响。退出会话恢复首页方向,返回后台会话沿用该会话的方向设置。已移除检测手机姿态后解除初始横屏的逻辑。
- 根据常见移动设备解码能力和屏幕尺寸设置起始参数:显示长边不超过 1280 像素时默认低画质(最高 720p)/30 fps,其余默认中画质(最高 1080p)/30 fps。用户保存的画面偏好不变,仍可手动选择高画质或 60 fps。 - 根据常见移动设备解码能力和屏幕尺寸设置起始参数:显示长边不超过 1280 像素时默认低画质(最高 720p)/30 fps,其余默认中画质(最高 1080p)/30 fps。用户保存的画面偏好不变,仍可手动选择高画质或 60 fps。
- 网络状态按视频、音频、数据、合计展示收发速率和丢包率,并显示实际提交帧率、分辨率、画面延时、平滑 RTT、P2P/TURN 和实际 SRTP 状态。每秒刷新,过期或无效测量显示“—”。 - 网络状态按视频、音频、数据、合计展示收发速率和丢包率,并显示实际提交帧率、分辨率、画面延时、平滑 RTT、P2P/TURN 和实际 SRTP 状态。每秒刷新,过期或无效测量显示“—”。
- 可拖动的悬浮控制球、三列控制菜单;默认使用系统输入法,保留候选词、纠错和听写,确认文字后直接输入远端。电脑键盘固定底部,分“字母数字 / 符号功能”两页,共用末两行布局,按系统键盘高度适配横竖屏;统一切换、收起按钮和 Android 键盘触感反馈。 - 可拖动的悬浮控制球、三列控制菜单;默认使用系统输入法,保留候选词、纠错和听写,确认文字后直接输入远端。电脑键盘固定底部,分“字母数字 / 符号功能”两页,共用末两行布局,按系统键盘高度适配横竖屏;统一切换、收起按钮和 Android 键盘触感反馈。
@@ -19,7 +19,7 @@ final class BackgroundSession implements NativeSession.Listener {
RemoteVideoSettings videoSettings; RemoteVideoSettings videoSettings;
int signalState, selectedDisplay, width, height; int signalState, selectedDisplay, width, height;
boolean muted, remoteServiceAvailable, background, ended; boolean muted, remoteServiceAvailable, background, ended;
boolean waitingForLandscape; boolean autoLandscape;
File receivedFile; File receivedFile;
BackgroundSession(Context context, NativeSession session, String host, int port, String remote) { BackgroundSession(Context context, NativeSession session, String host, int port, String remote) {
@@ -326,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)); 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); 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(boolean waitForLandscape){ private void sessionScreen(boolean autoLandscape){
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)); installRoot("session",true);remoteOrientation.begin(autoLandscape,!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(){ controls=new SessionControls(this,ui,new SessionControls.Actions(){
public void disconnect(){confirmDisconnect();}public void display(){selectDisplay();}public void clipboard(){MainActivity.this.clipboard();} 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();} public void sendFile(){chooseFile();}public void saveFile(){saveReceivedFile();}public void secureAttention(){MainActivity.this.secureAttention();}
@@ -370,9 +370,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())); 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))); 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); 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)); 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); Switch autoLandscape=new Switch(this);autoLandscape.setContentDescription("连接建立后锁定横屏");autoLandscape.setChecked(preferences.getBoolean("autoLandscape",false));autoLandscape.setOnCheckedChangeListener((button,checked)->preferences.edit().putBoolean("autoLandscape",checked).apply());directionRow.addView(autoLandscape);direction.addView(directionRow);
footer(content,"手机连接后先进入横屏,将手机横过来后恢复跟随设备方向。关闭后始终跟随设备方向。平板和多窗口不受影响。"); 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; 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); 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); 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);
@@ -604,7 +604,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
// Transport readiness can be reported again after ICE changes its selected path. // Transport readiness can be reported again after ICE changes its selected path.
// The first success consumes pendingPassword; a duplicate must not save it again. // 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;} 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(preferences.getBoolean("autoLandscape",true)); try{history.save(serverKey(),connectedRemote,connectedRemote,hostPlatform,rememberPassword,pendingPassword);}catch(Exception error){toast("密码保存失败,本次连接仍可使用");}pendingPassword="";sessionScreen(preferences.getBoolean("autoLandscape",false));
if(!canUsePictureInPicture())prepareBackgroundSession(true); if(!canUsePictureInPicture())prepareBackgroundSession(true);
if(videoSettings.supported&&videoSettings.requestId==0)updateVideoSettings(videoSettings.selection); if(videoSettings.supported&&videoSettings.requestId==0)updateVideoSettings(videoSettings.selection);
if(foreground&&preferences.getBoolean("networkConsent",false))remoteVersionCheck.connected(); if(foreground&&preferences.getBoolean("networkConsent",false))remoteVersionCheck.connected();
@@ -682,7 +682,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
super.onConfigurationChanged(config); super.onConfigurationChanged(config);
releaseKeys(); releaseKeys();
if(!page.equals("session"))updateHomeOrientation(); if(!page.equals("session"))updateHomeOrientation();
else if(!isInPictureInPictureMode()&&(isTablet()||isInMultiWindowMode()))remoteOrientation.followDevice(); else if(!isInPictureInPictureMode())remoteOrientation.update(!isTablet()&&!isInMultiWindowMode());
sizePasswordDialog(); sizePasswordDialog();
if(privacyDialog instanceof PrivacyConsentDialog)((PrivacyConsentDialog)privacyDialog).updateSize(); if(privacyDialog instanceof PrivacyConsentDialog)((PrivacyConsentDialog)privacyDialog).updateSize();
if(controls!=null&&!pictureInPictureUi)controls.post(controls::constrainPanels); if(controls!=null&&!pictureInPictureUi)controls.post(controls::constrainPanels);
@@ -701,7 +701,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
retained.muted=muted;retained.selectedDisplay=selectedDisplay;retained.remoteServiceAvailable=remoteServiceAvailable; retained.muted=muted;retained.selectedDisplay=selectedDisplay;retained.remoteServiceAvailable=remoteServiceAvailable;
retained.clipboard=remoteClipboard;retained.videoSettings=videoSettings;retained.receivedFile=receivedFile; retained.clipboard=remoteClipboard;retained.videoSettings=videoSettings;retained.receivedFile=receivedFile;
retained.width=videoWidth;retained.height=videoHeight; retained.width=videoWidth;retained.height=videoHeight;
retained.waitingForLandscape=remoteOrientation.waitingForLandscape(); retained.autoLandscape=remoteOrientation.autoLandscape();
} }
private boolean prepareBackgroundSession(boolean inform){ private boolean prepareBackgroundSession(boolean inform){
if(!ready||session==null||session.isClosed()||isFinishing()||!hasNetworkConsent())return false; if(!ready||session==null||session.isClosed()||isFinishing()||!hasNetworkConsent())return false;
@@ -737,7 +737,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
hostPlatform=retained.platform;displays=retained.displays;muted=retained.muted;selectedDisplay=retained.selectedDisplay; hostPlatform=retained.platform;displays=retained.displays;muted=retained.muted;selectedDisplay=retained.selectedDisplay;
remoteServiceAvailable=retained.remoteServiceAvailable;remoteClipboard=retained.clipboard;receivedFile=retained.receivedFile; remoteServiceAvailable=retained.remoteServiceAvailable;remoteClipboard=retained.clipboard;receivedFile=retained.receivedFile;
videoSettings=retained.videoSettings;ready=true;retained.attach(this); videoSettings=retained.videoSettings;ready=true;retained.attach(this);
if(rebuild)sessionScreen(retained.waitingForLandscape); if(rebuild)sessionScreen(retained.autoLandscape);
if(retained.width>0&&retained.height>0)videoSize(retained.width,retained.height); 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);} if(controls!=null){controls.setMuted(muted);controls.videoSettings(videoSettings);controls.fileStatus(retained.fileStatus,receivedFile!=null);}
retained.setBackground(false);signaling(signalState,localIdentity); retained.setBackground(false);signaling(signalState,localIdentity);
@@ -785,9 +785,9 @@ public final class MainActivity extends Activity implements NativeSession.Listen
super.onPictureInPictureUiStateChanged(state); super.onPictureInPictureUiStateChanged(state);
if(android.os.Build.VERSION.SDK_INT>=35&&state.isTransitioningToPip())setPictureInPictureUi(true); if(android.os.Build.VERSION.SDK_INT>=35&&state.isTransitioningToPip())setPictureInPictureUi(true);
} }
@Override protected void onResume(){super.onResume();privacyChanged();remoteOrientation.resume();if(!isInPictureInPictureMode())enteredPictureInPicture=false;setPictureInPictureUi(isInPictureInPictureMode());updatePictureInPicture();if(ready&&!canUsePictureInPicture())prepareBackgroundSession(true);if(backgroundSession!=null)requestBackgroundNotifications();} @Override protected void onResume(){super.onResume();privacyChanged();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. // onStart marks a new visit; focus/resume callbacks must not reopen a refused notice.
@Override protected void onPause(){remoteOrientation.pause();releaseKeys();if(controls!=null)controls.cancelMouseInput();if(video!=null)video.releaseMouse();super.onPause();} @Override protected void onPause(){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 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(); @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(); if(connectionDialog!=null)connectionDialog.dismiss();
@@ -2,54 +2,27 @@ package cn.crossdesk.mobile;
import android.app.Activity; import android.app.Activity;
import android.content.pm.ActivityInfo; import android.content.pm.ActivityInfo;
import android.view.OrientationEventListener;
/** Holds the initial landscape until the phone itself has been turned sideways. */ /** Applies the selected orientation policy for the whole remote session. */
final class RemoteOrientation { final class RemoteOrientation {
private final Activity activity; private final Activity activity;
private final OrientationEventListener sensor; private boolean autoLandscape;
private boolean waitingForLandscape, resumed;
RemoteOrientation(Activity activity) { RemoteOrientation(Activity activity) {
this.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) { void begin(boolean autoLandscape, boolean fullScreenPhone) {
sensor.disable(); this.autoLandscape = autoLandscape;
waitingForLandscape = autoLandscape && sensor.canDetectOrientation(); update(fullScreenPhone);
apply();
if (resumed && waitingForLandscape) sensor.enable();
} }
boolean waitingForLandscape() { return waitingForLandscape; } boolean autoLandscape() { return autoLandscape; }
void resume() { void end() { autoLandscape = false; }
resumed = true;
if (waitingForLandscape && !activity.isInPictureInPictureMode()) sensor.enable();
}
void pause() { resumed = false; sensor.disable(); } void update(boolean fullScreenPhone) {
int orientation = autoLandscape && fullScreenPhone
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_SENSOR_LANDSCAPE
: ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR; : ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR;
if (activity.getRequestedOrientation() != orientation) activity.setRequestedOrientation(orientation); if (activity.getRequestedOrientation() != orientation) activity.setRequestedOrientation(orientation);
+1 -1
View File
@@ -9,7 +9,7 @@
两者仍为开发版。已完成的模拟器验证及未完成的真机验收见各端说明。 两者仍为开发版。已完成的模拟器验证及未完成的真机验收见各端说明。
手机控制端的“设置 → 屏幕方向 → 连接后自动横屏”默认开启并保存在本机。连接后先保持横屏,检测到手机实际横过来后恢复跟随设备方向;竖持、平放或斜持不会解除初始横屏。关闭后沿用自动旋转,平板和多窗口不受此选项影响。退出会话恢复首页方向,返回同一后台会话不重复锁定已解锁的方向;重力传感器不可用时沿用自动旋转。 手机控制端的“设置 → 屏幕方向 → 连接建立后锁定横屏”默认关闭并保存在本机。开启后,手机连接时进入并保持横屏;关闭时跟随设备方向。平板和多窗口不受此选项影响。退出会话恢复首页方向,返回后台会话沿用该会话的方向设置。已移除检测手机姿态后解除初始横屏的逻辑。
## 构建 ## 构建
@@ -51,7 +51,7 @@ export class MobileState {
muted: boolean = false; muted: boolean = false;
previews: boolean = false; previews: boolean = false;
videoPreference: number = 1; videoPreference: number = 1;
autoLandscape: boolean = true; autoLandscape: boolean = false;
version: string = '0.1.0'; version: string = '0.1.0';
update: string = ''; update: string = '';
updateStatus: string = ''; updateStatus: string = '';
@@ -132,7 +132,7 @@ export class MobileController {
this.state.consent = this.store.flag('consent'); this.state.server = this.store.text('server', 'api.crossdesk.cn'); 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.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.relative = this.store.flag('relative', true); this.state.videoPreference = this.store.number('videoPreference', 1);
this.state.autoLandscape = this.store.flag('autoLandscape', true); this.state.autoLandscape = this.store.flag('autoLandscape', false);
this.state.previews = this.store.flag('previews'); this.state.previews = this.store.flag('previews');
try { this.state.version = bundleManager.getBundleInfoForSelfSync(bundleManager.BundleFlag.GET_BUNDLE_INFO_DEFAULT).versionName; } catch {} try { this.state.version = bundleManager.getBundleInfoForSelfSync(bundleManager.BundleFlag.GET_BUNDLE_INFO_DEFAULT).versionName; } catch {}
try { try {
@@ -4,7 +4,6 @@ import { PiPWindow, window, display } from '@kit.ArkUI';
import { backgroundTaskManager } from '@kit.BackgroundTasksKit'; import { backgroundTaskManager } from '@kit.BackgroundTasksKit';
import { picker, fileIo as fs, fileUri } from '@kit.CoreFileKit'; import { picker, fileIo as fs, fileUri } from '@kit.CoreFileKit';
import { http } from '@kit.NetworkKit'; import { http } from '@kit.NetworkKit';
import { sensor } from '@kit.SensorServiceKit';
import { isAnnouncementURL } from './AnnouncementText'; import { isAnnouncementURL } from './AnnouncementText';
export class MobileLifecycle { export class MobileLifecycle {
@@ -22,19 +21,9 @@ export class MobilePlatform {
private generation: number = 0; private generation: number = 0;
private backgroundActive: boolean = false; private backgroundActive: boolean = false;
private active: boolean = false; private active: boolean = false;
private waitingForLandscape: boolean = false; private autoLandscape: boolean = false;
private observingOrientation: boolean = false;
private orientationRevision: number = 0; private orientationRevision: number = 0;
private orientationQueue: Promise<void> = Promise.resolve(); private orientationQueue: Promise<void> = 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; private foreground: boolean = true;
choosing: boolean = false; choosing: boolean = false;
pipActive: boolean = false; pipActive: boolean = false;
@@ -51,9 +40,9 @@ export class MobilePlatform {
this.context = context; this.ended = ended; this.release = release; this.feedback = feedback; this.context = context; this.ended = ended; this.release = release; this.feedback = feedback;
try { backgroundTaskManager.on('continuousTaskCancel', this.backgroundCancelled); } catch {} try { backgroundTaskManager.on('continuousTaskCancel', this.backgroundCancelled); } catch {}
} }
async connected(autoLandscape: boolean = true): Promise<void> { async connected(autoLandscape: boolean = false): Promise<void> {
if (this.active) return; if (this.active) return;
this.waitingForLandscape = autoLandscape && deviceInfo.deviceType === 'phone'; this.autoLandscape = autoLandscape;
this.active = true; const generation = ++this.generation; this.active = true; const generation = ++this.generation;
this.muted = false; this.orientation(true); this.muted = false; this.orientation(true);
this.backgroundQueue = this.backgroundQueue.then(async () => { this.backgroundQueue = this.backgroundQueue.then(async () => {
@@ -106,7 +95,7 @@ export class MobilePlatform {
} }
videoSize(width: number, height: number): void { try { this.pip?.updateContentSize(width, height); } catch { /* Not supported on this device. */ } } videoSize(width: number, height: number): void { try { this.pip?.updateContentSize(width, height); } catch { /* Not supported on this device. */ } }
async background(): Promise<void> { async background(): Promise<void> {
this.foreground = false; this.stopOrientationSensor(); this.release(); this.cancelRequests(); this.foreground = false; this.release(); this.cancelRequests();
this.onVisibility(this.pipActive); this.onVisibility(this.pipActive);
if (!this.active) return; if (!this.active) return;
const generation = this.generation; const generation = this.generation;
@@ -125,7 +114,7 @@ export class MobilePlatform {
} }
dispose(): void { this.disconnect(); try { backgroundTaskManager.off('continuousTaskCancel', this.backgroundCancelled); } catch {} } dispose(): void { this.disconnect(); try { backgroundTaskManager.off('continuousTaskCancel', this.backgroundCancelled); } catch {} }
disconnect(): void { disconnect(): void {
this.waitingForLandscape = false; this.stopOrientationSensor(); this.autoLandscape = false;
this.active = false; this.choosing = false; this.generation++; this.cancelRequests(); this.active = false; this.choosing = false; this.generation++; this.cancelRequests();
const pip = this.pip; this.pip = undefined; this.pipActive = false; const pip = this.pip; this.pip = undefined; this.pipActive = false;
if (pip) { try { pip.setAutoStartEnabled(false); pip.stopPiP().catch(() => {}); } catch { /* Already stopped. */ } } if (pip) { try { pip.setAutoStartEnabled(false); pip.stopPiP().catch(() => {}); } catch { /* Already stopped. */ } }
@@ -134,27 +123,10 @@ export class MobilePlatform {
}); });
this.orientation(false); this.orientation(false);
} }
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 {
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 { private orientation(session: boolean): void {
const generation = this.generation; const generation = this.generation;
const revision = ++this.orientationRevision; const revision = ++this.orientationRevision;
// Serialize window requests so an earlier landscape request cannot win after // Serialize window requests so the latest policy wins after a lifecycle change.
// the sensor has released it or the session has disconnected.
this.orientationQueue = this.orientationQueue.then(async () => { this.orientationQueue = this.orientationQueue.then(async () => {
if (generation !== this.generation || revision !== this.orientationRevision) return; if (generation !== this.generation || revision !== this.orientationRevision) return;
try { try {
@@ -167,10 +139,9 @@ export class MobilePlatform {
const rect = win.getWindowProperties().windowRect; const rect = win.getWindowProperties().windowRect;
const narrow = Math.min(rect.width, rect.height) / display.getDefaultDisplaySync().densityPixels < 600; const narrow = Math.min(rect.width, rect.height) / display.getDefaultDisplaySync().densityPixels < 600;
const fullScreen = win.getWindowStatus() === window.WindowStatusType.FULL_SCREEN; 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 : const orientation = !session && phone && narrow && fullScreen ? window.Orientation.PORTRAIT :
session && this.waitingForLandscape ? window.Orientation.AUTO_ROTATION_LANDSCAPE : window.Orientation.AUTO_ROTATION; session && this.autoLandscape && phone && narrow && fullScreen ?
window.Orientation.AUTO_ROTATION_LANDSCAPE : window.Orientation.AUTO_ROTATION;
await win.setPreferredOrientation(orientation); await win.setPreferredOrientation(orientation);
} catch { /* Window may already be going away. */ } } catch { /* Window may already be going away. */ }
}); });
@@ -269,12 +269,12 @@ struct Mobile {
}.padding(12).width('100%').backgroundColor(Color.White).borderRadius(26).border({ width: 0.5, color: MobileTheme.line }) }.padding(12).width('100%').backgroundColor(Color.White).borderRadius(26).border({ width: 0.5, color: MobileTheme.line })
this.SectionTitle('屏幕方向') this.SectionTitle('屏幕方向')
Row() { Row() {
Text('连接后自动横屏').fontSize(16).fontColor(MobileTheme.ink).layoutWeight(1) Text('连接建立后锁定横屏').fontSize(16).fontColor(MobileTheme.ink).layoutWeight(1)
Toggle({ type: ToggleType.Switch, isOn: this.state.autoLandscape }).selectedColor(MobileTheme.green) Toggle({ type: ToggleType.Switch, isOn: this.state.autoLandscape }).selectedColor(MobileTheme.green)
.onChange((value: boolean) => this.controller?.setAutoLandscape(value)).accessibilityText('连接后自动横屏') .onChange((value: boolean) => this.controller?.setAutoLandscape(value)).accessibilityText('连接建立后锁定横屏')
}.padding({ left: 16, right: 16, top: 10, bottom: 10 }).width('100%').backgroundColor(Color.White) }.padding({ left: 16, right: 16, top: 10, bottom: 10 }).width('100%').backgroundColor(Color.White)
.borderRadius(26).border({ width: 0.5, color: MobileTheme.line }) .borderRadius(26).border({ width: 0.5, color: MobileTheme.line })
Text('手机连接后先进入横屏,将手机横过来后恢复跟随设备方向。关闭后始终跟随设备方向。平板和多窗口不受影响。') Text('默认关闭。开启后,手机连接时进入并保持横屏;关闭时跟随设备方向。平板和多窗口不受影响。')
.fontSize(12).fontColor(MobileTheme.secondary).width('100%').padding({ left: 16, right: 16, top: 8, bottom: 12 }) .fontSize(12).fontColor(MobileTheme.secondary).width('100%').padding({ left: 16, right: 16, top: 8, bottom: 12 })
Column() { Column() {
this.SettingRow('服务器', this.customServer ? '自定义' : '默认', () => this.serverExpanded = !this.serverExpanded) this.SettingRow('服务器', this.customServer ? '自定义' : '默认', () => this.serverExpanded = !this.serverExpanded)
+3 -1
View File
@@ -840,9 +840,11 @@ private struct ServerSettingsView: View {
.pickerStyle(.segmented) .pickerStyle(.segmented)
} }
Section { Section {
Toggle("连接后自动横屏", isOn: $session.autoLandscape) Toggle("连接建立后锁定横屏", isOn: $session.autoLandscape)
} header: { } header: {
Text("屏幕方向") Text("屏幕方向")
} footer: {
Text("默认关闭。开启后,iPhone 连接时进入并保持横屏;关闭时跟随设备方向。iPad 不受影响。")
} }
Section { Section {
DisclosureGroup(isExpanded: $serverConfigurationExpanded) { DisclosureGroup(isExpanded: $serverConfigurationExpanded) {
@@ -15,79 +15,38 @@ final class CrossDeskAppDelegate: NSObject, UIApplicationDelegate {
@MainActor @MainActor
enum AppOrientation { enum AppOrientation {
nonisolated static let autoLandscapeKey = "crossdesk.mobile.auto-landscape" nonisolated static let autoLandscapeKey = "crossdesk.mobile.auto-landscape"
private static var sessionActive = false private static var sessionOrientations: UIInterfaceOrientationMask?
private static var waitingForLandscape = false
private static var deviceObserver: NSObjectProtocol?
nonisolated static func beginSession(autoLandscape: Bool) { nonisolated static func beginSession(autoLandscape: Bool) {
DispatchQueue.main.async { beginSessionOnMain(autoLandscape: autoLandscape) } DispatchQueue.main.async { beginSessionOnMain(autoLandscape: autoLandscape) }
} }
private static func beginSessionOnMain(autoLandscape: Bool) { private static func beginSessionOnMain(autoLandscape: Bool) {
// Repeated transport-ready callbacks must not lock an existing session again. // Preserve the selected policy through repeated transport-ready callbacks.
guard !sessionActive else { return } guard sessionOrientations == nil else { return }
sessionActive = true let orientations: UIInterfaceOrientationMask = autoLandscape ? .landscape : .allButUpsideDown
waitingForLandscape = autoLandscape && UIDevice.current.userInterfaceIdiom == .phone sessionOrientations = orientations
if waitingForLandscape { apply(to: orientations)
CrossDeskAppDelegate.supportedOrientations = .landscape
resumeObserving()
} else {
apply(to: .allButUpsideDown)
}
} }
nonisolated static func endSession() { nonisolated static func endSession() {
DispatchQueue.main.async { DispatchQueue.main.async {
sessionActive = false sessionOrientations = nil
waitingForLandscape = false
stopObserving()
apply(to: .portrait) apply(to: .portrait)
} }
} }
nonisolated static func resume() { nonisolated static func resume() {
DispatchQueue.main.async { resumeObserving() } DispatchQueue.main.async {
} apply(to: sessionOrientations ?? .portrait)
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) { private static func apply(to orientations: UIInterfaceOrientationMask) {
// iPad keeps every orientation available, including in resizable windows. // iPad keeps every orientation available, including in resizable windows.
guard UIDevice.current.userInterfaceIdiom != .pad else { return } guard UIDevice.current.userInterfaceIdiom != .pad else { return }
CrossDeskAppDelegate.supportedOrientations = orientations CrossDeskAppDelegate.supportedOrientations = orientations
guard UIApplication.shared.applicationState == .active else { return }
for case let scene as UIWindowScene in UIApplication.shared.connectedScenes { for case let scene as UIWindowScene in UIApplication.shared.connectedScenes {
scene.windows.forEach { scene.windows.forEach {
$0.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations() $0.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
@@ -312,7 +312,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
@Published var videoAdaptationPolicy = VideoAdaptationPolicy.saved { @Published var videoAdaptationPolicy = VideoAdaptationPolicy.saved {
didSet { videoAdaptationPolicy.save() } didSet { videoAdaptationPolicy.save() }
} }
@Published var autoLandscape = UserDefaults.standard.object(forKey: AppOrientation.autoLandscapeKey) as? Bool ?? true { @Published var autoLandscape = UserDefaults.standard.object(forKey: AppOrientation.autoLandscapeKey) as? Bool ?? false {
didSet { UserDefaults.standard.set(autoLandscape, forKey: AppOrientation.autoLandscapeKey) } didSet { UserDefaults.standard.set(autoLandscape, forKey: AppOrientation.autoLandscapeKey) }
} }
@Published private(set) var videoSettings = RemoteVideoSettingsState() @Published private(set) var videoSettings = RemoteVideoSettingsState()
@@ -457,7 +457,6 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
func applicationDidEnterBackground() { func applicationDidEnterBackground() {
appIsActive = false appIsActive = false
AppOrientation.suspend()
appUpdates.setEnabled(false) appUpdates.setEnabled(false)
privacyNotice.didEnterBackground() privacyNotice.didEnterBackground()
suspendVideoForBackground() suspendVideoForBackground()
+7 -8
View File
@@ -214,14 +214,13 @@ the available space. Settings and reading pages are limited to 720 points.
Resizing preserves the connection fields and active session. iPhone retains its Resizing preserves the connection fields and active session. iPhone retains its
portrait home screen and two-column recent-connection grid. portrait home screen and two-column recent-connection grid.
On iPhone, Settings → Screen orientation (屏幕方向) → Landscape after connecting On iPhone, Settings → Screen orientation (屏幕方向) → Lock landscape after connecting
(连接后自动横屏) is enabled by default and saved locally. A new session starts in (连接建立后锁定横屏) is disabled by default and saved locally. When enabled, the session
landscape and keeps that orientation until the phone is physically held sideways; enters and stays in landscape; when disabled, it follows the device orientation.
it then resumes the existing device-following policy for the rest of the session. Leaving the session restores portrait, and returning to a background session keeps
Portrait, flat and unknown device poses do not release the initial landscape hold. that session's orientation setting. The physical-device monitoring that released
Turning the option off keeps the existing rotation behavior. Leaving the session the initial landscape hold has been removed. iPad orientation and multitasking
restores portrait; foregrounding the same session does not reapply a released hold. are unaffected.
iPad orientation and multitasking are unaffected.
## System keyboard and remote text ## System keyboard and remote text