mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-03 04:22:43 +08:00
[feat] add configurable landscape entry for mobile sessions
This commit is contained in:
@@ -9,6 +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,6 +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;
|
||||||
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) {
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
|||||||
private int videoWidth,videoHeight;
|
private int videoWidth,videoHeight;
|
||||||
private final RemotePictureInPicture pictureInPicture=new RemotePictureInPicture();
|
private final RemotePictureInPicture pictureInPicture=new RemotePictureInPicture();
|
||||||
private RemoteVideoView video;
|
private RemoteVideoView video;
|
||||||
|
private RemoteOrientation remoteOrientation;
|
||||||
private SessionControls controls;
|
private SessionControls controls;
|
||||||
private Dialog passwordDialog, progressDialog, privacyDialog;
|
private Dialog passwordDialog, progressDialog, privacyDialog;
|
||||||
private AlertDialog disconnectDialog;
|
private AlertDialog disconnectDialog;
|
||||||
@@ -95,7 +96,7 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
|||||||
private Runnable backAction;
|
private Runnable backAction;
|
||||||
|
|
||||||
@Override public void onCreate(Bundle state){
|
@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);
|
pageHost=new PageHost(this);setContentView(pageHost);
|
||||||
preferences=getSharedPreferences("settings",MODE_PRIVATE);host=preferences.getString("host","api.crossdesk.cn");port=preferences.getInt("port",9099);
|
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);};
|
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);
|
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;
|
return item;
|
||||||
}
|
}
|
||||||
@android.annotation.SuppressLint("SourceLockedOrientationActivity") // Phones keep the portrait home; tablets and multi-window follow user rotation settings.
|
private boolean isTablet(){
|
||||||
private void updateHomeOrientation(){
|
|
||||||
boolean tablet=getResources().getConfiguration().smallestScreenWidthDp>=600;
|
boolean tablet=getResources().getConfiguration().smallestScreenWidthDp>=600;
|
||||||
if(android.os.Build.VERSION.SDK_INT>=30){
|
if(android.os.Build.VERSION.SDK_INT>=30){
|
||||||
android.graphics.Rect bounds=getWindowManager().getMaximumWindowMetrics().getBounds();
|
android.graphics.Rect bounds=getWindowManager().getMaximumWindowMetrics().getBounds();
|
||||||
tablet|=Math.min(bounds.width(),bounds.height())>=dp(600);
|
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);
|
if(getRequestedOrientation()!=orientation)setRequestedOrientation(orientation);
|
||||||
}
|
}
|
||||||
private void home(String message){
|
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));
|
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(){
|
private void sessionScreen(boolean waitForLandscape){
|
||||||
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));
|
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(){
|
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();}
|
||||||
@@ -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 confirmDisconnect(){disconnectDialog=new AlertDialog.Builder(this).setTitle("断开远程连接?").setMessage("断开后将返回首页").setNegativeButton("取消",null).setPositiveButton("断开连接",(d,w)->end("")).show();}
|
||||||
private void end(String reason){
|
private void end(String reason){
|
||||||
|
remoteOrientation.end();
|
||||||
if(backgroundSession!=null){BackgroundSession retained=backgroundSession;backgroundSession=null;retained.detach(this);retained.finish(reason);}
|
if(backgroundSession!=null){BackgroundSession retained=backgroundSession;backgroundSession=null;retained.detach(this);retained.finish(reason);}
|
||||||
boolean closeWindow=isInPictureInPictureMode();
|
boolean closeWindow=isInPictureInPictureMode();
|
||||||
pictureInPicture.disconnect();updatePictureInPicture();
|
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()));
|
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));
|
||||||
|
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;
|
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);
|
||||||
@@ -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.
|
// 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();
|
try{history.save(serverKey(),connectedRemote,connectedRemote,hostPlatform,rememberPassword,pendingPassword);}catch(Exception error){toast("密码保存失败,本次连接仍可使用");}pendingPassword="";sessionScreen(preferences.getBoolean("autoLandscape",true));
|
||||||
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();
|
||||||
@@ -670,6 +679,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();
|
||||||
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);
|
||||||
@@ -688,6 +698,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();
|
||||||
}
|
}
|
||||||
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;
|
||||||
@@ -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;
|
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();
|
if(rebuild)sessionScreen(retained.waitingForLandscape);
|
||||||
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);
|
||||||
@@ -771,9 +782,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();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.
|
// 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 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();
|
||||||
@@ -791,5 +802,5 @@ public final class MainActivity extends Activity implements NativeSession.Listen
|
|||||||
boolean retained=!isFinishing()&&backgroundSession!=null&&!backgroundSession.ended&&RemoteSessionService.current()==backgroundSession;
|
boolean retained=!isFinishing()&&backgroundSession!=null&&!backgroundSession.ended&&RemoteSessionService.current()==backgroundSession;
|
||||||
if(backgroundSession!=null)backgroundSession.detach(this);
|
if(backgroundSession!=null)backgroundSession.detach(this);
|
||||||
if(!retained){if(session!=null)session.close();stopSignaling();}else{mainHandler.removeCallbacks(presenceTick);session=signaling=null;}
|
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:
|
在仓库根目录执行,当前构建脚本面向 macOS:
|
||||||
|
|||||||
@@ -50,6 +50,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;
|
||||||
version: string = '0.1.0';
|
version: string = '0.1.0';
|
||||||
update: string = '';
|
update: string = '';
|
||||||
updateStatus: 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.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.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 {
|
||||||
@@ -280,6 +282,7 @@ export class MobileController {
|
|||||||
this.settingsTimeout = setTimeout(() => { this.video.expire(id); this.syncVideo(); }, 5000); this.syncVideo();
|
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; }
|
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 {
|
setPreviews(value: boolean): void {
|
||||||
this.store.put('previews', value); this.state.previews = value;
|
this.store.put('previews', value); this.state.previews = value;
|
||||||
if (!value) { this.send({ op: 'cancelPreview' }); this.previewEligible = false; this.store.clearPreviews(); this.history(); }
|
if (!value) { this.send({ op: 'cancelPreview' }); this.previewEligible = false; this.store.clearPreviews(); this.history(); }
|
||||||
@@ -427,7 +430,7 @@ export class MobileController {
|
|||||||
this.history();
|
this.history();
|
||||||
}
|
}
|
||||||
}).catch(() => this.toast('密码保存失败,本次连接仍可使用'));
|
}).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.video.supported && this.video.requestId === 0) this.updateVideo(0, this.video.selection.quality);
|
||||||
if (this.receivedHostInfo) this.checkRemote(this.remoteVersion);
|
if (this.receivedHostInfo) this.checkRemote(this.remoteVersion);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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 {
|
||||||
@@ -21,6 +22,19 @@ 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 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;
|
private foreground: boolean = true;
|
||||||
choosing: boolean = false;
|
choosing: boolean = false;
|
||||||
pipActive: boolean = false;
|
pipActive: boolean = false;
|
||||||
@@ -37,7 +51,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(): 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.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 () => {
|
||||||
@@ -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. */ } }
|
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.release(); this.cancelRequests();
|
this.foreground = false; this.stopOrientationSensor(); 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;
|
||||||
@@ -109,6 +125,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.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. */ } }
|
||||||
@@ -117,18 +134,46 @@ export class MobilePlatform {
|
|||||||
});
|
});
|
||||||
this.orientation(false);
|
this.orientation(false);
|
||||||
}
|
}
|
||||||
private async orientation(session: boolean): Promise<void> {
|
private stopOrientationSensor(): void {
|
||||||
const generation = this.generation;
|
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 {
|
try {
|
||||||
const win = await window.getLastWindow(this.context);
|
sensor.getSingleSensorSync(sensor.SensorId.GRAVITY);
|
||||||
if (generation !== this.generation) return;
|
sensor.on(sensor.SensorId.GRAVITY, this.gravityChanged, { interval: 100000000 });
|
||||||
await win.setWindowKeepScreenOn(session);
|
this.observingOrientation = true;
|
||||||
if (generation !== this.generation) return;
|
} catch {
|
||||||
// Tablets and resizable windows follow the user orientation in both views.
|
// A phone without a usable sensor keeps the previous system-following policy.
|
||||||
const phone = deviceInfo.deviceType === 'phone';
|
this.waitingForLandscape = false;
|
||||||
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. */ }
|
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 {
|
private setChoosing(value: boolean): void {
|
||||||
this.choosing = value; this.release();
|
this.choosing = value; this.release();
|
||||||
|
|||||||
@@ -267,6 +267,15 @@ struct Mobile {
|
|||||||
MobileSegment({ labels: ['帧率优先', '画质优先', '平衡'], selection: this.state.videoPreference,
|
MobileSegment({ labels: ['帧率优先', '画质优先', '平衡'], selection: this.state.videoPreference,
|
||||||
changed: (i: number) => this.controller?.setPreference(i) })
|
changed: (i: number) => this.controller?.setPreference(i) })
|
||||||
}.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('屏幕方向')
|
||||||
|
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() {
|
Column() {
|
||||||
this.SettingRow('服务器', this.customServer ? '自定义' : '默认', () => this.serverExpanded = !this.serverExpanded)
|
this.SettingRow('服务器', this.customServer ? '自定义' : '默认', () => this.serverExpanded = !this.serverExpanded)
|
||||||
if (this.serverExpanded) {
|
if (this.serverExpanded) {
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if !session.sessionVisible {
|
if !session.sessionVisible {
|
||||||
AppOrientation.update(to: .portrait)
|
AppOrientation.endSession()
|
||||||
}
|
}
|
||||||
if scenePhase == .active {
|
if scenePhase == .active {
|
||||||
session.applicationDidBecomeActive()
|
session.applicationDidBecomeActive()
|
||||||
@@ -840,6 +840,11 @@ private struct ServerSettingsView: View {
|
|||||||
}
|
}
|
||||||
.pickerStyle(.segmented)
|
.pickerStyle(.segmented)
|
||||||
}
|
}
|
||||||
|
Section {
|
||||||
|
Toggle("连接后自动横屏", isOn: $session.autoLandscape)
|
||||||
|
} header: {
|
||||||
|
Text("屏幕方向")
|
||||||
|
}
|
||||||
Section {
|
Section {
|
||||||
DisclosureGroup(isExpanded: $serverConfigurationExpanded) {
|
DisclosureGroup(isExpanded: $serverConfigurationExpanded) {
|
||||||
Toggle("使用自定义服务器", isOn: $usesCustomServer)
|
Toggle("使用自定义服务器", isOn: $usesCustomServer)
|
||||||
|
|||||||
@@ -12,24 +12,88 @@ final class CrossDeskAppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
enum AppOrientation {
|
enum AppOrientation {
|
||||||
static func update(to orientations: UIInterfaceOrientationMask) {
|
nonisolated static let autoLandscapeKey = "crossdesk.mobile.auto-landscape"
|
||||||
DispatchQueue.main.async {
|
private static var sessionActive = false
|
||||||
// iPad windows can rotate and resize independently of the session.
|
private static var waitingForLandscape = false
|
||||||
// Keep all orientations available and let the window manager choose
|
private static var deviceObserver: NSObjectProtocol?
|
||||||
// the geometry; the phone still locks the home screen to portrait.
|
|
||||||
guard UIDevice.current.userInterfaceIdiom != .pad else { return }
|
|
||||||
CrossDeskAppDelegate.supportedOrientations = orientations
|
|
||||||
|
|
||||||
for case let scene as UIWindowScene in UIApplication.shared.connectedScenes {
|
nonisolated static func beginSession(autoLandscape: Bool) {
|
||||||
scene.windows.forEach {
|
DispatchQueue.main.async { beginSessionOnMain(autoLandscape: autoLandscape) }
|
||||||
$0.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
|
}
|
||||||
}
|
|
||||||
scene.requestGeometryUpdate(
|
private static func beginSessionOnMain(autoLandscape: Bool) {
|
||||||
.iOS(interfaceOrientations: orientations)
|
// Repeated transport-ready callbacks must not lock an existing session again.
|
||||||
) { error in
|
guard !sessionActive else { return }
|
||||||
NSLog("CrossDesk orientation update failed: %@", error.localizedDescription)
|
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 {
|
@Published var videoAdaptationPolicy = VideoAdaptationPolicy.saved {
|
||||||
didSet { videoAdaptationPolicy.save() }
|
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 private(set) var videoSettings = RemoteVideoSettingsState()
|
||||||
@Published var remoteID = ""
|
@Published var remoteID = ""
|
||||||
@Published var password = ""
|
@Published var password = ""
|
||||||
@@ -437,6 +440,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
|||||||
|
|
||||||
func applicationDidBecomeActive() {
|
func applicationDidBecomeActive() {
|
||||||
appIsActive = true
|
appIsActive = true
|
||||||
|
AppOrientation.resume()
|
||||||
pictureInPicture.didBecomeActive()
|
pictureInPicture.didBecomeActive()
|
||||||
appUpdates.setEnabled(hasNetworkConsent)
|
appUpdates.setEnabled(hasNetworkConsent)
|
||||||
privacyNotice.didBecomeActive(hasNetworkConsent: hasNetworkConsent)
|
privacyNotice.didBecomeActive(hasNetworkConsent: hasNetworkConsent)
|
||||||
@@ -447,6 +451,7 @@ 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()
|
||||||
@@ -591,6 +596,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func beginRemoteConnection(_ identifier: String) {
|
private func beginRemoteConnection(_ identifier: String) {
|
||||||
|
AppOrientation.endSession()
|
||||||
networkStatistics = RemoteNetworkStatistics()
|
networkStatistics = RemoteNetworkStatistics()
|
||||||
resetRemoteHostInfo()
|
resetRemoteHostInfo()
|
||||||
cancelVideoRecovery()
|
cancelVideoRecovery()
|
||||||
@@ -714,7 +720,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
|||||||
selectedDisplay = 0
|
selectedDisplay = 0
|
||||||
connectionStatus = "未连接"
|
connectionStatus = "未连接"
|
||||||
audioPlayer.endSession()
|
audioPlayer.endSession()
|
||||||
AppOrientation.update(to: .portrait)
|
AppOrientation.endSession()
|
||||||
appUpdates.checkNow()
|
appUpdates.checkNow()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1191,7 +1197,7 @@ final class RemoteSessionModel: NSObject, ObservableObject, CrossDeskRTCBridgeDe
|
|||||||
recordSuccessfulConnectionIfNeeded()
|
recordSuccessfulConnectionIfNeeded()
|
||||||
audioPlayer.setEnabled(audioEnabled)
|
audioPlayer.setEnabled(audioEnabled)
|
||||||
bridge.setAudioEnabled(audioEnabled)
|
bridge.setAudioEnabled(audioEnabled)
|
||||||
AppOrientation.update(to: .allButUpsideDown)
|
AppOrientation.beginSession(autoLandscape: autoLandscape)
|
||||||
case 2: connectionStatus = "正在收集 ICE 候选"
|
case 2: connectionStatus = "正在收集 ICE 候选"
|
||||||
case 3: finishConnection(withError: "网络连接已中断,请重新连接。")
|
case 3: finishConnection(withError: "网络连接已中断,请重新连接。")
|
||||||
case 4:
|
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
|
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
|
||||||
|
(连接后自动横屏) 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
|
## System keyboard and remote text
|
||||||
|
|
||||||
The session keyboard button opens the iPhone/iPad's selected system keyboard.
|
The session keyboard button opens the iPhone/iPad's selected system keyboard.
|
||||||
|
|||||||
Reference in New Issue
Block a user