[feat] add configurable landscape entry for mobile sessions

This commit is contained in:
dijunkun
2026-10-24 03:28:23 +08:00
parent 182899d238
commit 4650e48ea3
12 changed files with 257 additions and 44 deletions
+1
View File
@@ -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 键盘触感反馈。
@@ -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) {
@@ -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();}
}
@@ -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);
}
}
+2
View File
@@ -9,6 +9,8 @@
两者仍为开发版。已完成的模拟器验证及未完成的真机验收见各端说明。
手机控制端的“设置 → 屏幕方向 → 连接后自动横屏”默认开启并保存在本机。连接后先保持横屏,检测到手机实际横过来后恢复跟随设备方向;竖持、平放或斜持不会解除初始横屏。关闭后沿用自动旋转,平板和多窗口不受此选项影响。退出会话恢复首页方向,返回同一后台会话不重复锁定已解锁的方向;重力传感器不可用时沿用自动旋转。
## 构建
在仓库根目录执行,当前构建脚本面向 macOS:
@@ -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);
}
@@ -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<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;
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<void> {
async connected(autoLandscape: boolean = true): Promise<void> {
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<void> {
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<void> {
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();
@@ -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) {
+6 -1
View File
@@ -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)
@@ -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)
}
}
}
@@ -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:
+9
View File
@@ -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.