Skip to content

Commit be6d314

Browse files
committed
feat: 实现多屏幕选择串流
1 parent f3fdba9 commit be6d314

8 files changed

Lines changed: 273 additions & 43 deletions

File tree

app/build.gradle

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ android {
1919
minSdk 22
2020
targetSdk 35
2121

22-
versionName "12.4.6"
23-
versionCode = 351
22+
versionName "12.4.7"
23+
versionCode = 352
2424

2525
// Generate native debug symbols to allow Google Play to symbolicate our native crashes
2626
ndk.debugSymbolLevel = 'FULL'

app/src/main/java/com/limelight/AppView.java

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
import com.limelight.nvstream.http.NvApp;
1515
import com.limelight.nvstream.http.NvHTTP;
1616
import com.limelight.nvstream.http.PairingManager;
17+
import com.limelight.nvstream.http.NvHTTP.DisplayInfo;
18+
import com.limelight.binding.PlatformBinding;
1719
import com.limelight.preferences.PreferenceConfiguration;
1820
import com.limelight.ui.AdapterFragment;
1921
import com.limelight.ui.AdapterFragmentCallbacks;
@@ -27,6 +29,9 @@
2729
import com.limelight.utils.SpinnerDialog;
2830
import com.limelight.utils.UiHelper;
2931
import com.limelight.utils.AppSettingsManager;
32+
import com.limelight.LimeLog;
33+
import com.limelight.Game;
34+
import com.limelight.binding.PlatformBinding;
3035

3136
import android.annotation.SuppressLint;
3237
import android.app.Activity;
@@ -101,6 +106,11 @@ public class AppView extends Activity implements AdapterFragmentCallbacks {
101106
private LinearLayout lastSettingsInfo;
102107
private TextView lastSettingsText;
103108
private CheckBox useLastSettingsCheckbox;
109+
110+
// 显示器选择相关
111+
private LinearLayout displaySelectionInfo;
112+
private android.widget.RadioGroup displayRadioGroup;
113+
private List<DisplayInfo> availableDisplays;
104114

105115
private final static int START_OR_RESUME_ID = 1;
106116
private final static int QUIT_ID = 2;
@@ -355,6 +365,10 @@ protected void onCreate(Bundle savedInstanceState) {
355365
lastSettingsText = findViewById(R.id.lastSettingsText);
356366
useLastSettingsCheckbox = findViewById(R.id.useLastSettingsCheckbox);
357367

368+
// Initialize display selection UI components
369+
displaySelectionInfo = findViewById(R.id.displaySelectionInfo);
370+
displayRadioGroup = findViewById(R.id.displayRadioGroup);
371+
358372
// Set up event listeners
359373
useLastSettingsCheckbox.setOnCheckedChangeListener((buttonView, isChecked) -> {
360374
appSettingsManager.setUseLastSettingsEnabled(isChecked);
@@ -401,6 +415,11 @@ protected void onCreate(Bundle savedInstanceState) {
401415
// Bind to the computer manager service
402416
bindService(new Intent(this, ComputerManagerService.class), serviceConnection,
403417
Service.BIND_AUTO_CREATE);
418+
419+
// Delay checking displays to allow service connection to complete
420+
new Handler(Looper.getMainLooper()).postDelayed(() -> {
421+
checkDisplaysAndUpdateUI();
422+
}, 500);
404423
}
405424

406425
private void updateHiddenApps(boolean hideImmediately) {
@@ -551,7 +570,6 @@ private void handleSelectionChange(int position, AppObject app) {
551570
isFirstFocus = false; // 第一次后设置为false
552571
}
553572

554-
// 更新上一次设置信息显示
555573
updateLastSettingsInfo(app);
556574
}
557575

@@ -591,14 +609,115 @@ private void updateLastSettingsInfo(AppObject app) {
591609
* @param app 应用对象
592610
*/
593611
private void startStreamWithLastSettingsIfEnabled(AppObject app) {
612+
String displayGuid = null;
613+
if (displaySelectionInfo.getVisibility() == View.VISIBLE
614+
&& availableDisplays != null) {
615+
int selectedId = displayRadioGroup.getCheckedRadioButtonId();
616+
if (selectedId >= 0 && selectedId < availableDisplays.size()) {
617+
DisplayInfo selectedDisplay = availableDisplays.get(selectedId);
618+
displayGuid = selectedDisplay.guid != null && !selectedDisplay.guid.isEmpty()
619+
? selectedDisplay.guid : selectedDisplay.name;
620+
}
621+
}
622+
623+
doStartStream(app, displayGuid);
624+
}
625+
626+
/**
627+
* 检查显示器并更新UI
628+
*/
629+
private void checkDisplaysAndUpdateUI() {
630+
if (computer == null || computer.activeAddress == null || managerBinder == null) {
631+
displaySelectionInfo.setVisibility(View.GONE);
632+
return;
633+
}
634+
635+
new Thread(() -> {
636+
try {
637+
NvHTTP httpConn = new NvHTTP(computer.activeAddress, computer.httpsPort,
638+
managerBinder.getUniqueId(), "", computer.serverCert,
639+
PlatformBinding.getCryptoProvider(this));
640+
641+
List<DisplayInfo> displays = httpConn.getDisplays();
642+
643+
runOnUiThread(() -> {
644+
if (displays != null && displays.size() > 1) {
645+
updateDisplaySelectionUI(displays);
646+
} else {
647+
displaySelectionInfo.setVisibility(View.GONE);
648+
}
649+
});
650+
} catch (Exception e) {
651+
LimeLog.warning("Failed to get displays: " + e.getMessage());
652+
runOnUiThread(() -> displaySelectionInfo.setVisibility(View.GONE));
653+
}
654+
}).start();
655+
}
656+
657+
/**
658+
* 更新显示器选择UI
659+
*
660+
* @param displays 显示器列表
661+
*/
662+
private void updateDisplaySelectionUI(List<DisplayInfo> displays) {
663+
availableDisplays = displays;
664+
665+
// 清除之前的单选按钮
666+
displayRadioGroup.removeAllViews();
667+
668+
LimeLog.info("Displays: " + displays.size());
669+
for (int i = 0; i < displays.size(); i++) {
670+
DisplayInfo display = displays.get(i);
671+
// 使用友好名字显示
672+
String displayName = display.name != null && !display.name.isEmpty()
673+
? display.name : "Display " + (display.index + 1);
674+
LimeLog.info("Display " + (display.index + 1) + ": " + display.name + " (guid: " + display.guid + ")");
675+
676+
// 创建单选按钮
677+
android.widget.RadioButton radioButton = new android.widget.RadioButton(this);
678+
radioButton.setId(i);
679+
radioButton.setText(displayName);
680+
radioButton.setTextColor(0xCCFFFFFF);
681+
radioButton.setTextSize(12);
682+
radioButton.setTypeface(android.graphics.Typeface.create("sans-serif-light", android.graphics.Typeface.NORMAL));
683+
radioButton.setButtonTintList(android.content.res.ColorStateList.valueOf(0xFFFFFFFF));
684+
radioButton.setPadding(0, 0, 20, 0); // 右边距
685+
686+
displayRadioGroup.addView(radioButton);
687+
}
688+
689+
// 默认不选择任何显示器
690+
displayRadioGroup.clearCheck();
691+
692+
displaySelectionInfo.setVisibility(View.VISIBLE);
693+
}
694+
695+
/**
696+
* 执行启动串流
697+
*
698+
* @param app 应用对象
699+
* @param displayName 选择的显示器名称,如果为null则不指定显示器
700+
*/
701+
private void doStartStream(AppObject app, String displayName) {
594702
if (appSettingsManager != null && computer != null) {
595703
// 使用AppSettingsManager统一管理启动逻辑
596704
Intent startIntent = appSettingsManager.createStartIntentWithLastSettingsIfEnabled(
597705
this, app.app, computer, managerBinder);
706+
if (displayName != null) {
707+
startIntent.putExtra(Game.EXTRA_DISPLAY_NAME, displayName);
708+
}
598709
startActivity(startIntent);
599710
} else {
600711
// 回退到默认方式启动
601-
ServerHelper.doStart(this, app.app, computer, managerBinder);
712+
if (displayName != null) {
713+
Intent startIntent = ServerHelper.createStartIntent(this, app.app, computer, managerBinder);
714+
startIntent.putExtra(Game.EXTRA_DISPLAY_NAME, displayName);
715+
startActivity(startIntent);
716+
} else {
717+
if (computer != null) {
718+
ServerHelper.doStart(this, app.app, computer, managerBinder);
719+
}
720+
}
602721
}
603722
}
604723

app/src/main/java/com/limelight/Game.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,7 @@ private void stopAndUnbindUsbDriverService() {
326326
public static final String EXTRA_SERVER_CERT = "ServerCert";
327327
public static final String EXTRA_PC_USEVDD = "usevdd";
328328
public static final String EXTRA_APP_CMD = "CmdList";
329+
public static final String EXTRA_DISPLAY_NAME = "DisplayName";
329330

330331
private ExternalDisplayManager externalDisplayManager;
331332

@@ -506,6 +507,7 @@ public boolean onCapturedPointer(View view, MotionEvent motionEvent) {
506507
boolean pcUseVdd = Game.this.getIntent().getBooleanExtra(EXTRA_PC_USEVDD, false);
507508
byte[] derCertData = Game.this.getIntent().getByteArrayExtra(EXTRA_SERVER_CERT);
508509
String cmdList = Game.this.getIntent().getStringExtra(EXTRA_APP_CMD);
510+
String displayName = Game.this.getIntent().getStringExtra(EXTRA_DISPLAY_NAME);
509511

510512
app = new NvApp(appName != null ? appName : "app", appId, appSupportsHdr);
511513
if (cmdList != null) {
@@ -695,7 +697,7 @@ public void notifyCrash(Exception e) {
695697
conn = new NvConnection(getApplicationContext(),
696698
new ComputerDetails.AddressTuple(host, port),
697699
httpsPort, uniqueId, pairName, config,
698-
PlatformBinding.getCryptoProvider(this), serverCert);
700+
PlatformBinding.getCryptoProvider(this), serverCert, displayName);
699701
controllerHandler = new ControllerHandler(this, conn, this, prefConfig);
700702
keyboardTranslator = new KeyboardTranslator();
701703

app/src/main/java/com/limelight/nvstream/ConnectionContext.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,7 @@ public class ConnectionContext {
3636
public int minBrightness;
3737
public int maxBrightness;
3838
public int maxAverageBrightness;
39+
40+
// 选择的显示器名称
41+
public String displayName;
3942
}

app/src/main/java/com/limelight/nvstream/NvConnection.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ public class NvConnection {
5151
private ComputerDetails.AddressTuple host;
5252

5353
public NvConnection(Context appContext, ComputerDetails.AddressTuple host, int httpsPort, String uniqueId, String pairName, StreamConfiguration config, LimelightCryptoProvider cryptoProvider, X509Certificate serverCert)
54+
{
55+
this(appContext, host, httpsPort, uniqueId, pairName, config, cryptoProvider, serverCert, null);
56+
}
57+
58+
public NvConnection(Context appContext, ComputerDetails.AddressTuple host, int httpsPort, String uniqueId, String pairName, StreamConfiguration config, LimelightCryptoProvider cryptoProvider, X509Certificate serverCert, String displayName)
5459
{
5560
this.appContext = appContext;
5661
this.host = host;
@@ -63,6 +68,7 @@ public NvConnection(Context appContext, ComputerDetails.AddressTuple host, int h
6368
this.context.httpsPort = httpsPort;
6469
this.context.streamConfig = config;
6570
this.context.serverCert = serverCert;
71+
this.context.displayName = displayName;
6672

6773
// This is unique per connection
6874
this.context.riKey = generateRiAesKey();

app/src/main/java/com/limelight/nvstream/http/NvHTTP.java

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.limelight.nvstream.http;
22

3+
import android.os.Build;
34
import android.provider.Settings;
45
import java.io.FileNotFoundException;
56
import java.io.IOException;
@@ -21,13 +22,18 @@
2122
import java.security.cert.Certificate;
2223
import java.security.cert.CertificateException;
2324
import java.security.cert.X509Certificate;
25+
import java.util.ArrayList;
2426
import java.util.LinkedList;
27+
import java.util.List;
2528
import java.util.ListIterator;
2629
import java.util.Objects;
2730
import java.util.Stack;
2831
import java.util.UUID;
2932
import java.util.concurrent.TimeUnit;
3033

34+
import org.json.JSONArray;
35+
import org.json.JSONObject;
36+
3137
import javax.net.ssl.HostnameVerifier;
3238
import javax.net.ssl.HttpsURLConnection;
3339
import javax.net.ssl.KeyManager;
@@ -780,6 +786,59 @@ public InputStream getBoxArt(NvApp app) throws IOException, InterruptedException
780786
return resp.byteStream();
781787
}
782788

789+
/**
790+
* 获取主机可用的显示器列表
791+
* @return 显示器列表,每个显示器包含 index 和 name
792+
* @throws IOException 如果请求失败
793+
* @throws InterruptedException 如果请求被中断
794+
*/
795+
public static class DisplayInfo {
796+
public int index;
797+
public String name; // 友好名字,用于显示
798+
public String guid; // GUID,用于传递给服务端
799+
800+
public DisplayInfo(int index, String name, String guid) {
801+
this.index = index;
802+
this.name = name;
803+
this.guid = guid;
804+
}
805+
}
806+
807+
public List<DisplayInfo> getDisplays() throws IOException, InterruptedException {
808+
try {
809+
String jsonStr = openHttpConnectionToString(httpClientLongConnectTimeout, getHttpsUrl(true), "displays");
810+
JSONObject json = new JSONObject(jsonStr);
811+
812+
int statusCode = json.optInt("status_code", 0);
813+
if (statusCode != 200) {
814+
throw new IOException("Failed to get displays: " + json.optString("status_message", "Unknown error"));
815+
}
816+
817+
JSONArray displaysArray = json.optJSONArray("displays");
818+
if (displaysArray == null) {
819+
return new ArrayList<>();
820+
}
821+
822+
List<DisplayInfo> displays = new ArrayList<>(displaysArray.length());
823+
for (int i = 0; i < displaysArray.length(); i++) {
824+
JSONObject displayObj = displaysArray.getJSONObject(i);
825+
826+
String friendlyName = displayObj.optString("friendly_name", "");
827+
if (friendlyName.isEmpty()) {
828+
friendlyName = displayObj.optString("display_name", "Display " + (i + 1));
829+
}
830+
831+
String guid = displayObj.optString("device_id", "");
832+
833+
displays.add(new DisplayInfo(i, friendlyName, guid));
834+
}
835+
836+
return displays;
837+
} catch (org.json.JSONException e) {
838+
throw new IOException("Failed to parse displays response: " + e.getMessage(), e);
839+
}
840+
}
841+
783842
public int getServerMajorVersion(String serverInfo) throws XmlPullParserException, IOException {
784843
return getServerAppVersionQuad(serverInfo)[0];
785844
}
@@ -834,8 +893,7 @@ public boolean launchApp(ConnectionContext context, String verb, int appId, bool
834893
}
835894
}
836895

837-
String xmlStr = openHttpConnectionToString(httpClientLongConnectNoReadTimeout, getHttpsUrl(true), verb,
838-
"appid=" + appId +
896+
String queryParams = "appid=" + appId +
839897
"&mode=" + context.streamConfig.getReqWidth() + "x" + context.streamConfig.getReqHeight() + "x" + fps +
840898
"&additionalStates=1&sops=" + (enableSops ? 1 : 0) +
841899
"&resolutionScale=" + context.streamConfig.getResolutionScale() +
@@ -849,8 +907,16 @@ public boolean launchApp(ConnectionContext context, String verb, int appId, bool
849907
"&gcpersist=" + (context.streamConfig.getPersistGamepadsAfterDisconnect() ? 1 : 0) + "&useVdd="+(context.streamConfig.getUseVdd() ? 1 : 0) +
850908
"&minBrightness=" + context.minBrightness +
851909
"&maxBrightness=" + context.maxBrightness +
852-
"&maxAverageBrightness=" + context.maxAverageBrightness +
853-
MoonBridge.getLaunchUrlQueryParameters());
910+
"&maxAverageBrightness=" + context.maxAverageBrightness;
911+
912+
// 如果指定了显示器GUID,添加到查询参数中
913+
if (context.displayName != null && !context.displayName.isEmpty()) {
914+
queryParams += "&display_name=" + context.displayName;
915+
}
916+
917+
queryParams += MoonBridge.getLaunchUrlQueryParameters();
918+
919+
String xmlStr = openHttpConnectionToString(httpClientLongConnectNoReadTimeout, getHttpsUrl(true), verb, queryParams);
854920
if ((verb.equals("launch") && !getXmlString(xmlStr, "gamesession", true).equals("0") ||
855921
(verb.equals("resume") && !getXmlString(xmlStr, "resume", true).equals("0")))) {
856922
// sessionUrl0 will be missing for older GFE versions

0 commit comments

Comments
 (0)