mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-10-24 05:33:08 +08:00
[feat] add configurable landscape entry for mobile sessions
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user