diff --git a/app/build.gradle b/app/build.gradle
index 1b1eb5ae7..8ba8c8ea0 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -337,6 +337,8 @@ dependencies {
implementation 'androidx.camera:camera-camera2:1.4.2'
implementation 'androidx.camera:camera-lifecycle:1.4.2'
implementation 'androidx.webkit:webkit:1.12.1'
+ // Google's WebRTC (BSD) for the native Xbox Cloud Gaming player (XboxNativePlayerActivity).
+ implementation 'io.github.webrtc-sdk:android:125.6422.07'
implementation("com.google.mediapipe:tasks-vision:${trackingPack.MEDIAPIPE_VERSION}") {
exclude group: 'com.google.auto.value', module: 'auto-value'
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 286a9ee8a..29718e087 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -152,6 +152,27 @@
android:process=":xboxlogin" android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density"
android:theme="@style/AppTheme" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/assets/xcloud/player.js b/app/src/main/assets/xcloud/player.js
new file mode 100644
index 000000000..3d3762149
--- /dev/null
+++ b/app/src/main/assets/xcloud/player.js
@@ -0,0 +1,187 @@
+/* DroidDeck Xbox Cloud Gaming player. Includes Greenlight's player (c) UnknownSKL (Jim Kroon), MIT. */
+(()=>{var _=class{_player;_dataChannel;constructor(e){this._player=e,this._dataChannel=this._player._peerConnection.createDataChannel(this.getChannelName(),this.getChannelConfig()),this._dataChannel.onopen=this.onOpen.bind(this),this._dataChannel.onmessage=this.onMessage.bind(this),this._dataChannel.onclosing=this.onClosing.bind(this),this._dataChannel.onclose=this.onClose.bind(this),this._dataChannel.onerror=this.onError.bind(this)}getChannelName(){return"channel"}getChannelConfig(){return{}}onOpen(e){console.log(this.getChannelName(),"Opening channel:",e)}onMessage(e){console.log(this.getChannelName(),"Message channel:",e)}onClosing(e){console.log(this.getChannelName(),"Closing channel:",e)}onClose(e){console.log(this.getChannelName(),"Closed channel:",e)}onError(e){console.log(this.getChannelName(),"Error channel:",e)}send(e){if(this._dataChannel.readyState!=="open"){console.log("["+this.getChannelName()+"] Tried to send data while channel is not open. readyState is:",this._dataChannel.readyState);return}return typeof e=="string"&&(e=new TextEncoder().encode(e)),this._dataChannel.send(e)}getPlayer(){return this._player}destroy(){}};var S=class extends _{_micPermissions="prompt";_micStream;constructor(e){super(e),navigator.permissions.query({name:"microphone"}).then(t=>{this._micPermissions=t.state,t.onchange=()=>{this._micPermissions=t.state}})}getChannelName(){return"chat"}getChannelConfig(){return{ordered:!0,protocol:"chatV1"}}destroy(){}};var v=class r{_player;_index;_physicalGamepadId=-1;_rumbleInterval;_isFocused=!0;_gamepadOverride={buttons:{}};_options={enable_keyboard:!1,enable_gamepad:!0,enable_vibration:!0,keyboard_mapping:{A:"Enter",B:"Backspace",X:"x",Y:"y",DPadUp:"ArrowUp",DPadDown:"ArrowDown",DPadLeft:"ArrowLeft",DPadRight:"ArrowRight",LeftShoulder:"[",RightShoulder:"]",LeftThumb:"l",RightThumb:"r",LeftTrigger:"-",RightTrigger:"=",Menu:"m",View:"v",Nexus:"n"},gamepad_mapping:{A:"0",B:"1",X:"2",Y:"3",DPadUp:"12",DPadDown:"13",DPadLeft:"14",DPadRight:"15",LeftShoulder:"4",RightShoulder:"5",LeftThumb:"10",RightThumb:"11",LeftTrigger:"6",RightTrigger:"7",Menu:"9",View:"8",Nexus:"16"},gamepad_axes_mapping:{LeftThumbXAxis:"0",LeftThumbYAxis:"1",RightThumbXAxis:"2",RightThumbYAxis:"3"},gamepad_deadzone:.08,gamepad_force_capture:!1};_listener={keyDown:this.onKeyDown.bind(this),keyUp:this.onKeyUp.bind(this),gamepadConnected:this.onGamepadConnected.bind(this),gamepadDisconnected:this.onGamepadDisconnected.bind(this),windowFocus:this.onWindowFocus.bind(this),windowBlur:this.onWindowBlur.bind(this)};constructor(e,t={}){this._index=e,this._options={...this._options,...t}}attach(e){this._player=e,this._player._channels.control.sendGamepadState(this._index,!0,this),this._options.enable_keyboard===!0&&(window.addEventListener("keydown",this._listener.keyDown),window.addEventListener("keyup",this._listener.keyUp)),this._options.enable_gamepad===!0&&(window.addEventListener("gamepadconnected",this._listener.gamepadConnected),window.addEventListener("gamepaddisconnected",this._listener.gamepadDisconnected)),this._options.enable_gamepad===!0&&this._options.gamepad_force_capture===!1&&(window.addEventListener("blur",this._listener.windowBlur),window.addEventListener("focus",this._listener.windowFocus)),this.detectActiveGamepad()}detach(){if(this._player===void 0){console.log("[Gamepad] Player is not attached. this._player is:",this._player);return}this._player._channels.control.sendGamepadState(this._index,!1),this._physicalGamepadId=-1,this._rumbleInterval!==void 0&&clearInterval(this._rumbleInterval),this._options.enable_keyboard===!0&&(window.removeEventListener("keydown",this._listener.keyDown),window.removeEventListener("keyup",this._listener.keyUp)),this._options.enable_gamepad===!0&&(window.removeEventListener("gamepadconnected",this._listener.gamepadConnected),window.removeEventListener("gamepaddisconnected",this._listener.gamepadDisconnected)),this._options.enable_gamepad===!0&&this._options.gamepad_force_capture===!1&&(window.removeEventListener("blur",this._listener.windowBlur),window.removeEventListener("focus",this._listener.windowFocus))}sendButtonState(e,t){if(this._player===void 0){console.log("[Gamepad] Player is not attached. this._player is:",this._player);return}if(!(e in this.getDefaultFamepadFrame())){console.log("[Gamepad] Invalid button:",e);return}let n=this.getDefaultFamepadFrame();n.GamepadIndex=this._index,this._gamepadOverride.buttons[e]=t}onKeyDown(e){if(this._isFocused!==!1)for(let t in this._options.keyboard_mapping)this._options.keyboard_mapping[t]===e.key&&(this._gamepadOverride.buttons[t]=1,e.preventDefault())}onKeyUp(e){if(this._isFocused!==!1)for(let t in this._options.keyboard_mapping)this._options.keyboard_mapping[t]===e.key&&(this._gamepadOverride.buttons[t]=0,e.preventDefault())}onWindowFocus(){this._isFocused=!0}onWindowBlur(){}getDefaultFamepadFrame(){return{GamepadIndex:0,Nexus:0,Menu:0,View:0,A:0,B:0,X:0,Y:0,DPadUp:0,DPadDown:0,DPadLeft:0,DPadRight:0,LeftShoulder:0,RightShoulder:0,LeftThumb:0,RightThumb:0,LeftThumbXAxis:0,LeftThumbYAxis:0,RightThumbXAxis:0,RightThumbYAxis:0,LeftTrigger:0,RightTrigger:0}}detectActiveGamepad(){if(this._physicalGamepadId<0)for(let e in navigator.getGamepads()){let t=navigator.getGamepads()[e]?.index;if(t===void 0)continue;let n=!1;for(let i in this._player?._channels.control.getGamepadHandlers())if(this._player?._channels.control.getGamepadHandlers()[i]instanceof r&&(this._player?._channels.control.getGamepadHandlers()[i])._physicalGamepadId===navigator.getGamepads()[e]?.index){console.log("[Gamepad] Active gamepad already detected:",t,(this._player?._channels.control.getGamepadHandlers()[i])._physicalGamepadId),n=!0;continue}n===!1&&(this._physicalGamepadId=t,console.log("[Gamepad] Active gamepad detected:",this._physicalGamepadId))}else console.log("[Gamepad] Physical gamepad already detected:",this._physicalGamepadId)}onGamepadConnected(e){if(this._physicalGamepadId<0){for(let t in this._player?._channels.control.getGamepadHandlers())if(this._player?._channels.control.getGamepadHandlers()[t]instanceof r&&(this._player?._channels.control.getGamepadHandlers()[t])._physicalGamepadId===e.gamepad.index)return;this._physicalGamepadId=e.gamepad.index}}onGamepadDisconnected(e){e.gamepad.index===this._physicalGamepadId&&(this._physicalGamepadId=-1)}hasOverrideButtons(){return Object.keys(this._gamepadOverride.buttons).length>0}getGamepadState(){let e=this.getGamepad(this._physicalGamepadId);if((this._physicalGamepadId<0||e===void 0||this._isFocused===!1)&&this.hasOverrideButtons()===!1)return;let t=this.getDefaultFamepadFrame();if(t.GamepadIndex=this._index,e!==void 0){for(let i in this._options.gamepad_mapping)t[i]=e.buttons[this._options.gamepad_mapping[i]]?.value??0;for(let i in this._options.gamepad_axes_mapping)t[i]=this.normaliseAxis(e.axes[this._options.gamepad_axes_mapping[i]]??0)}for(let i in this._gamepadOverride.buttons)t[i]=this._gamepadOverride.buttons[i];let n=[];for(let i in this._gamepadOverride.buttons)this._gamepadOverride.buttons[i]===0&&n.push(i);for(let i of n)delete this._gamepadOverride.buttons[i];return t.View>0&&t.Menu>0&&(t.View=0,t.Menu=0,t.Nexus=1),t}getGamepad(e=-1){let t=navigator.getGamepads()[e];if(t!==null)return t}getPhysicalGamepadId(){return this._physicalGamepadId}handleVibration(e){if(this._physicalGamepadId<0){console.log("[Gamepad] Received a vibration report but no physical gamepad is connected");return}let t=this.getGamepad(this._physicalGamepadId);if(t===void 0){console.log("[Gamepad] Received a vibration report but no physical gamepad is connected");return}if(this._options.enable_vibration===!1)return;let n={startDelay:0,duration:e.durationMs,weakMagnitude:e.rightMotorPercent*(window.__ddRumble??1),strongMagnitude:e.leftMotorPercent*(window.__ddRumble??1),leftTrigger:e.leftTriggerMotorPercent,rightTrigger:e.rightTriggerMotorPercent};if(t.vibrationActuator?.type==="dual-rumble"){let i=e.rightMotorPercent<.6?(.6-e.rightMotorPercent)/2:0,a=(e.leftTriggerMotorPercent+e.rightTriggerMotorPercent)/4,s=Math.min(i,a);if(n.weakMagnitude=Math.min(1,(e.rightMotorPercent+s)*(window.__ddRumble??1)),n.leftTrigger=0,n.rightTrigger=0,t.vibrationActuator?.playEffect("dual-rumble",n),clearInterval(this._rumbleInterval),e.repeat>0){let o=e.repeat;this._rumbleInterval=setInterval(()=>{o<=0&&clearInterval(this._rumbleInterval),t.vibrationActuator!==void 0&&t.vibrationActuator?.playEffect("dual-rumble",n),o--},e.delayMs+e.durationMs)}}else console.log("[Gamepad] Unknown vibration type:",t.vibrationActuator.type)}normaliseAxis(e){return Math.abs(e)0&&(this._reportType|=1,s=s+this._calculateMetadataSize(e),this._metadataFrames=e),t.length>0&&(this._reportType|=2,s=s+this._calculateGamepadSize(t),this._gamepadFrames=t),n.length>0&&(this._reportType|=4,s=s+this._calculatePointerSize(n),this._pointerFrames=n),i.length>0&&(this._reportType|=32,s=s+this._calculateMouseSize(i),this._mouseFrames=i),a.length>0&&(this._reportType|=64,s=s+this._calculateKeyboardSize(a),this._keyboardFrames=a),this._totalSize=s}_calculateMetadataSize(e){return 1+7*4*e.length}_calculateGamepadSize(e){return 1+23*e.length}_calculatePointerSize(e){let t=1;for(let n in e)t=t+1+e[n].events.length*20;return t}_calculateMouseSize(e){return 1+18*e.length}_calculateKeyboardSize(e){return 1+3*e.length}_writeMetadataData(e,t,n){e.setUint8(t,n.length),t++,n.length>=30&&console.warn("metadataQueue is bigger then 30. This might impact reliability!");for(let i=0;i=30&&console.warn("gamepadQueue is bigger then 30. This might impact reliability!");for(let i=0;i0&&(s|=2),a.Menu>0&&(s|=4),a.View>0&&(s|=8),a.A>0&&(s|=16),a.B>0&&(s|=32),a.X>0&&(s|=64),a.Y>0&&(s|=128),a.DPadUp>0&&(s|=256),a.DPadDown>0&&(s|=512),a.DPadLeft>0&&(s|=1024),a.DPadRight>0&&(s|=2048),a.LeftShoulder>0&&(s|=4096),a.RightShoulder>0&&(s|=8192),a.LeftThumb>0&&(s|=16384),a.RightThumb>0&&(s|=32768),e.setUint16(t,s,!0),e.setInt16(t+2,this._normalizeAxisValue(a.LeftThumbXAxis),!0),e.setInt16(t+4,this._normalizeAxisValue(-a.LeftThumbYAxis),!0),e.setInt16(t+6,this._normalizeAxisValue(a.RightThumbXAxis),!0),e.setInt16(t+8,this._normalizeAxisValue(-a.RightThumbYAxis),!0),e.setUint16(t+10,this._normalizeTriggerValue(a.LeftTrigger),!0),e.setUint16(t+12,this._normalizeTriggerValue(a.RightTrigger),!0),e.setUint32(t+14,1,!0),e.setUint32(t+18,1,!1),t+=22}return t}_writePointerData(e,t,n){e.setUint8(t,1),t++,n.length>=2&&console.warn("pointerQueue is bigger then 1. Only one event will be sent.");let i=n[0];if(i!==void 0){e.setUint8(t,i.events.length),t++;let a=this._serverVideoWidth,s=this._serverVideoHeight;for(let o=0;o=30&&console.warn("mouseQueue is bigger then 30. This might impact reliability!");for(let i=0;i=30&&console.warn("keyboardQueue is bigger then 30. This might impact reliability!");for(let i=0;i0&&(n=this._writeMetadataData(t,n,this._metadataFrames)),this._gamepadFrames.length>0&&(n=this._writeGamepadData(t,n,this._gamepadFrames)),this._pointerFrames.length>0&&(n=this._writePointerData(t,n,this._pointerFrames)),this._mouseFrames.length>0&&(n=this._writeMouseData(t,n,this._mouseFrames)),this._keyboardFrames.length>0&&(n=this._writeKeyboardData(t,n,this._keyboardFrames)),this._reportType===8&&(t.setUint8(n,this._maxTouchpoints),n++),n!==n)throw new Error("Packet length mismatch. Something is wrong!");return t}_normalizeTriggerValue(e){return e<=0||Number.isNaN(e)?0:e>=1?65535:e*65535|0}_normalizeAxisValue(e){let i=Math.round(e*32767);return i>32767?32767:i<-32767?-32767:i}};var F=class{_player;_inputSequenceNum=0;_metadataQueue=[];_gamepadQueue=[];_pointerQueue=[];_mouseQueue=[];_keyboardQueue=[];_gamepadHeartbeatTimers=new Map;_lastGamepadState=new Map;_axisChangeThreshold=.08;constructor(e){this._player=e}_hasQueuedData(){return this._metadataQueue.length>0||this._gamepadQueue.length>0||this._pointerQueue.length>0||this._mouseQueue.length>0||this._keyboardQueue.length>0}_upsertGamepadFrame(e){for(let t=0;tthis._axisChangeThreshold||o>.15)return!0}for(let a of i)if(Math.abs(e[a]-t[a])>.01)return!0;return!1}_isHeartbeatDue(e){let t=this._gamepadHeartbeatTimers.get(e)??0;return performance.now()-t>33}queueMetadataFrame(e){this._metadataQueue.push(e),this.checkQueueAndSend()}queueGamepadFrame(e){let t=this._lastGamepadState.get(e.GamepadIndex),n=this._hasSignificantAxisChange(e,t),i=this._isHeartbeatDue(e.GamepadIndex);(n||i)&&(this._upsertGamepadFrame(e),this._lastGamepadState.set(e.GamepadIndex,e),this._gamepadHeartbeatTimers.set(e.GamepadIndex,performance.now()),this.checkQueueAndSend())}queueGamepadFrames(e,t=!1){for(let n of e)this._upsertGamepadFrame(n);this.checkQueueAndSend(t)}queueMouseFrame(e){this._mouseQueue.push(e),this.checkQueueAndSend()}queueKeyboardFrame(e){this._keyboardQueue.push(e),this.checkQueueAndSend()}queuePointerFrame(e){this._pointerQueue.length>0?this._pointerQueue[0].events=this._pointerQueue[0].events.concat(e.events):this._pointerQueue.push(e),this.checkQueueAndSend()}checkQueueAndSend(e=!1){if(e===!0){this._hasQueuedData()&&this.sendQueue();return}if(this._gamepadQueue.length>0){this.sendQueue();return}if(this._mouseQueue.length>0||this._keyboardQueue.length>0||this._pointerQueue.length>0){this.sendQueue();return}this._metadataQueue.length>5&&this.sendQueue()}sendQueue(){let e=new y(this.getSequenceNum());this._pointerQueue.length>0&&e.setStreamConfig({width:this._player._channels.input.getServerVideoWidth(),height:this._player._channels.input.getServerVideoHeight()}),e.setData(this._metadataQueue,this._gamepadQueue,this._pointerQueue,this._mouseQueue,this._keyboardQueue),this._player._channels.input.send(e.toBuffer()),this.clearQueues()}clearQueues(){this._metadataQueue=[],this._gamepadQueue=[],this._pointerQueue=[],this._mouseQueue=[],this._keyboardQueue=[]}getSequenceNum(){return this._inputSequenceNum++,this._inputSequenceNum}};var A=class extends _{_serverVideoWidth=0;_serverVideoHeight=0;_inputQueue=new F(this.getPlayer());getChannelName(){return"input"}getChannelConfig(){return{ordered:!0,protocol:"1.0"}}getServerVideoWidth(){return this._serverVideoWidth}getServerVideoHeight(){return this._serverVideoHeight}start(){let e=new y(0);e.setMetadata(navigator.maxTouchPoints>1?navigator.maxTouchPoints:1),this.send(e.toBuffer()),console.log("[InputChannel] Sent metadata:",e,e.toBuffer()),setTimeout(this.gamepadStateLoop.bind(this),16)}onMessage(e){let t=new DataView(e.data),n=t.getUint8(0);if(console.log("[InputChannel] received:",t),n===128){console.log("Received a vibration report"),t.getUint8(2);let i=t.getUint8(3),a=this.getPlayer()._channels.control.getGamepadHandler(i);if(a!==void 0){let s=t.getUint8(4)/100,o=t.getUint8(5)/100,d=t.getUint8(6)/100,u=t.getUint8(7)/100,h=t.getUint16(8,!0),g=t.getUint16(10,!0),p=t.getUint8(12),c={gamepadIndex:i,leftMotorPercent:s,rightMotorPercent:o,leftTriggerMotorPercent:d,rightTriggerMotorPercent:u,durationMs:h,delayMs:g,repeat:p};a.handleVibration(c)}else console.log("[InputChannel] Received a vibration report but no gamepad handler is available")}else n===16&&(console.log("[InputChannel] Received server video dimensions:",t.getUint32(2,!0),t.getUint32(6,!0)),this._serverVideoHeight=t.getUint32(2,!0),this._serverVideoWidth=t.getUint32(6,!0))}gamepadStateLoop(){let e=this.getPlayer()._channels.control.getGamepadHandlers(),t=[];for(let n in e)if(e[n]instanceof v){let i=e[n].getGamepadState();i!==void 0&&t.push(i)}t.length>0&&this.queueGamepadFrames(t),setTimeout(this.gamepadStateLoop.bind(this),16)}queueMetadataFrame(e){return this._inputQueue.queueMetadataFrame(e)}queueGamepadFrames(e,t=!1){this._inputQueue.queueGamepadFrames(e,t)}queueMouseFrame(e){this._inputQueue.queueMouseFrame(e)}queueKeyboardFrame(e){this._inputQueue.queueKeyboardFrame(e)}queuePointerFrame(e){this._inputQueue.queuePointerFrame(e)}destroy(){}};function Q(){return crypto.randomUUID()}var D=class{_channel;_data;constructor(e,t){this._channel=e,this._data=t,this.handle(this._data)}handle(e){switch(console.log("[MessageHandler] Handling:",e),e.target){case"/streaming/sessionLifetimeManagement/serverInitiatedDisconnect":this.completeTransaction(e.id,""),this._channel.getPlayer().destroy();break;case"/streaming/properties/titleinfo":console.log("Title update:",e);break;case"/streaming/touchcontrols/showlayoutv2":break;case"/streaming/systemUi/messages/ShowMessageDialog":this.handleMessage(e);break;default:console.log("[MessageChannel] Unhandled transaction:",e)}}completeTransaction(e,t){let n=JSON.stringify({type:"TransactionComplete",content:JSON.stringify(t),id:e,cv:""});this._channel.send(n)}cancelTransaction(e,t){let n=JSON.stringify({type:"ReceiverCancel",content:JSON.stringify(t),id:e,cv:""});this._channel.send(n)}handleMessage(e){let t=JSON.parse(e.content);console.log(t),confirm(t.TitleText+`
+
+`+t.ContentText)?this.completeTransaction(e.id,{Result:0}):this.completeTransaction(e.id,{Result:1})}};var M=class extends _{_handshakeCompleted=!1;getChannelName(){return"message"}getChannelConfig(){return{protocol:"messageV1",ordered:!0}}onOpen(){console.log("[MessageChannel] Sending handshake...");let e=JSON.stringify({type:"Handshake",version:"messageV1",id:"be0bfc6d-1e83-4c8a-90ed-fa8601c5a179",cv:"0"});this.send(e)}onMessage(e){let t=JSON.parse(e.data);switch(t.type){case"HandshakeAck":console.log("[MessageChannel] Handshake completed"),this._handshakeCompleted=!0,this.getPlayer()._channels.control.sendAuthorization(),this.getPlayer()._channels.input.start(),this.sendConfig();break;case"TransactionStart":case"Message":new D(this,t);break;default:console.log("[MessageChannel] Unhandled message type:",t.type,t)}}sendConfig(){let e=[],t=[0,2,0],n=JSON.stringify(this.generateMessage("/streaming/systemUi/configuration",{version:t,systemUis:e}));this.send(n);let i=JSON.stringify(this.generateMessage("/streaming/properties/clientappinstallidchanged",{clientAppInstallId:"c97d7ee0-73b2-4239-bf1d-9d805a338429"}));this.send(i);let a=JSON.stringify(this.generateMessage("/streaming/characteristics/orientationchanged",{orientation:0}));this.send(a);let s=JSON.stringify(this.generateMessage("/streaming/characteristics/touchinputenabledchanged",{touchInputEnabled:!1}));this.send(s);let o=JSON.stringify(this.generateMessage("/streaming/characteristics/clientdevicecapabilities",{}));this.send(o);let d=JSON.stringify(this.generateMessage("/streaming/characteristics/dimensionschanged",{horizontal:1920,vertical:1080,preferredWidth:1920,preferredHeight:1080,safeAreaLeft:0,safeAreaTop:0,safeAreaRight:1920,safeAreaBottom:1080,supportsCustomResolution:!0}));this.send(d)}generateMessage(e,t){return{type:"Message",content:JSON.stringify(t),id:Q(),target:e,cv:""}}destroy(){}};var E=class{_address;_ipv4Address;_ipv4Port;constructor(e){this._address=e;let t=this._address.split(":"),n=t[6]+t[7],i=t[5],a=~parseInt(n.substring(0,2),16)&255,s=~parseInt(n.substring(2,4),16)&255,o=~parseInt(n.substring(4,6),16)&255,d=~parseInt(n.substring(6,8),16)&255,u=~parseInt(i.substring(0,4),16)&65535;this._ipv4Address=a+"."+s+"."+o+"."+d,this._ipv4Port=u}getIpv4Address(){return this._ipv4Address}getIpv4Port(){return this._ipv4Port}};var R=class{_peerConnection;_iceCandidates=[];constructor(e){this._peerConnection=e._peerConnection,this._peerConnection.addEventListener("icecandidate",t=>{t.candidate&&this._iceCandidates.push(t.candidate)})}getCandidates(){return this._iceCandidates}setRemoteCandidates(e){e.length===0&&console.warn("Error: No candidates received!");for(let t in e){if(e[t].candidate==="a=end-of-candidates")continue;let n=e[t].candidate.split(" ");if(n.length>4&&n[4].substr(0,4)==="2001"){let i=new E(n[4]);this._peerConnection.addIceCandidate({candidate:"a=candidate:10 1 UDP 1 "+i.getIpv4Address()+" 9002 typ host ",sdpMLineIndex:e[t].sdpMLineIndex,sdpMid:e[t].sdpMid}),this._peerConnection.addIceCandidate({candidate:"a=candidate:11 1 UDP 1 "+i.getIpv4Address()+" "+i.getIpv4Port()+" typ host ",sdpMLineIndex:e[t].sdpMLineIndex,sdpMid:e[t].sdpMid})}this._peerConnection.addIceCandidate({candidate:e[t].candidate,sdpMid:e[t].sdpMid,sdpMLineIndex:e[t].sdpMLineIndex})}}};var I=class{_player;_peerConnection;constructor(e){this._player=e,this._peerConnection=e._peerConnection}getAvailableCodecs(){let e=RTCRtpReceiver.getCapabilities("video")?.codecs;if(e===void 0)return[];let t=[];for(let n in e)t.includes(e[n].mimeType)||t.push(e[n].mimeType);return t}setLocalSDP(e){return console.log("[SDP] setLocalSDP() Setting local SDP:",e),e.sdp=e.sdp?.replace("useinbandfec=1","useinbandfec=1; stereo=1"),e}setRemoteSDP(e){return console.log("[SDP] setRemoteSDP() Setting remote SDP:",e),e}getDefaultCodecPreferences(){let e=RTCRtpReceiver.getCapabilities("video")?.codecs;if(e===void 0)return[];let t=[],n=[],i=[],a=[];for(let o in e)e[o].mimeType.includes("H264")?e[o].sdpFmtpLine?.includes("profile-level-id=4d")?t.push(e[o]):e[o].sdpFmtpLine?.includes("profile-level-id=42e")?n.push(e[o]):e[o].sdpFmtpLine?.includes("profile-level-id=420")&&i.push(e[o]):(e[o].mimeType.includes("ulpfec")||e[o].mimeType.includes("flexfec")||e[o].mimeType.includes("VP9")||e[o].mimeType.includes("VP8"))&&a.push(e[o]);return[...t,...n,...i,...a]}_setBitrate(e,t,n){let i=e.split(`
+`),a=-1;for(let o=0;o{e.forEach(t=>{t.type==="inbound-rtp"?this.readInboundRtp(t):t.type==="candidate-pair"&&t.packetsReceived!==void 0&&t.packetsReceived>0?this.readCandidatePair(t):t.type==="remote-candidate"&&this.readRemoteCandidate(t)})})}readInboundRtp(e){e.kind==="video"&&e.codecId&&e.packetsReceived!==void 0&&e.packetsReceived>0?(e.codecId.includes("profile-level-id=4d")?this._videoCodec="H264 (High)":e.codecId.includes("profile-level-id=42e")?this._videoCodec="H264 (Normal)":e.codecId.includes("profile-level-id=420")?this._videoCodec="H264 (Low)":this._videoCodec="Other ("+e.codecId+")",e.frameWidth&&(this._videoWidth=e.frameWidth),e.frameHeight&&(this._videoHeight=e.frameHeight),e.framesPerSecond&&(this._videoFps=e.framesPerSecond)):e.kind==="audio"&&e.codecId&&e.packetsReceived!==void 0&&e.packetsReceived>0&&(this._audioCodec=e.codecId)}readCandidatePair(e){this._activeRemoteCandidate=e.remoteCandidateId,this._rtt=e.currentRoundTripTime}readRemoteCandidate(e){e.id===this._activeRemoteCandidate&&(this.isPrivateIP(e.address)?this._remoteIsLocal=!0:this._remoteIsLocal=!1,this.isIpv6(e.address)?this._remoteIsIpv6=!0:this._remoteIsIpv6=!1,this._remoteHost=e.address,this._remotePort=e.port)}isPrivateIP(e){let t=e.split(".");return t[0]==="10"||t[0]==="172"&&parseInt(t[1],10)>=16&&parseInt(t[1],10)<=31||t[0]==="192"&&t[1]==="168"}isIpv6(e){return e.split(":").length>3}};var G=class{_player;_videoComponent;_overlays={debug:void 0};constructor(e,t){this._videoComponent=e,this._player=t}toggleDebug(){this._overlays.debug===void 0?this.createDebugOverlay():this.destroy()}createDebugOverlay(){this._overlays.debug=document.createElement("div"),this._overlays.debug.id="playerDebugOverlay",this._overlays.debug.style.position="absolute",this._overlays.debug.style.top="0",this._overlays.debug.style.left="0",this._overlays.debug.style.width=this._videoComponent.getElement()?.style.width||"100%",this._overlays.debug.style.height=this._videoComponent.getElement()?.style.height||"auto",this._overlays.debug.style.background="linear-gradient(0deg, rgba(0,0,0, 0) 0%, rgba(0,0,0, 0) 75%, rgba(0,0,0, 0.5) 100%)",this._overlays.debug.style.padding="10px",this._overlays.debug.style.pointerEvents="none",document.getElementById(this._player.getElementId())?.appendChild(this._overlays.debug),setTimeout(()=>{this.refreshDebugOverlay()},500),this.refreshDebugOverlay()}destroy(){this._overlays.debug?.remove(),this._overlays.debug=void 0}refreshDebugOverlay(){if(this._overlays.debug!==void 0){this._overlays.debug.innerHTML="",this._overlays.debug.appendChild(this.createLabel("Local Play",this._player.getStats()._remoteIsLocal?"Local":"Remote","ok")),this._overlays.debug.appendChild(this.createLabel("Resolution",this._player.getStats()._videoWidth+"x"+this._player.getStats()._videoHeight,"ok")),this._overlays.debug.appendChild(this.createLabel("Stream FPS",this._player.getStats()._videoFps.toString(),this._player.getStats()._videoFps>=58?"ok":"warning"));let e=this._videoComponent.getFrameStats?.();e&&(this._overlays.debug.appendChild(this.createLabel("Render FPS",e.renderingFps.toString(),e.renderingFps>=58?"ok":"warning")),this._overlays.debug.appendChild(this.createLabel("Render Delay",e.renderDelayMs.toString()+" ms",e.renderDelayMs<16?"ok":e.renderDelayMs<33?"warning":"error")),this._overlays.debug.appendChild(this.createLabel("Dropped Frames",e.droppedFrames.toString(),e.droppedFrames===0?"ok":e.droppedFrames<30?"warning":"error"))),this._overlays.debug.appendChild(this.createLabel("Connection",this._player.getStats()._remoteIsIpv6?"IPv6":"IPv4","ok")),this._overlays.debug.appendChild(this.createLabel("Video",this._player.getStats()._videoCodec,"ok"));let t=this._player.getStats()._rtt*1e3>75?"error":this._player.getStats()._rtt*1e3>40?"warning":"ok";this._overlays.debug.appendChild(this.createLabel("RTT",(this._player.getStats()._rtt*1e3).toString()+" ms",t)),setTimeout(()=>{this.refreshDebugOverlay()},500)}}createLabel(e,t="",n=""){let i={borderRadius:"5px",background:"linear-gradient(0deg, rgba(28,26,26,1) 0%, rgba(47,45,45,1) 100%)",padding:"10px",fontSize:"12px",marginTop:"3px",marginRight:"10px",marginBottom:"2px",textTransform:"uppercase",color:"white",display:"inline-block"},a=document.createElement("span"),s={error:"rgb(220, 53, 69)",warning:"rgb(255, 193, 7)",ok:"rgb(25, 135, 84)"};t!==""?a.innerHTML=`${e}: ${t}`:a.innerText=e;for(let o in i)a.style[o]=i[o];return a}};var L=class{_player;_element;_overlay;constructor(e){this._player=e,this._overlay=new G(this,this._player)}create(e){let t=document.createElement("video");t.srcObject=e,t.autoplay=!0,t.muted=!0,t.playsInline=!0,t.style.width="100%",t.style.height="100%",t.style.objectFit="contain",t.style.backgroundColor="black",t.style.touchAction="none",t.poster="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";let n=document.getElementById(this._player.getElementId());if(n===null)return;this._element=t,n.appendChild(this._element),getComputedStyle(n).position==="static"===!0&&(n.style.position="relative"),this._element.requestVideoFrameCallback(this.processVideoMetadata.bind(this))}processVideoMetadata(e,t){this._element!==void 0&&(this._element.requestVideoFrameCallback(this.processVideoMetadata.bind(this)),this._player._channels.input.queueMetadataFrame({serverDataKey:t.rtpTimestamp,firstFramePacketArrivalTimeMs:t.receiveTime,frameSubmittedTimeMs:t.receiveTime,frameDecodedTimeMs:t.expectedDisplayTime,frameRenderedTimeMs:t.expectedDisplayTime}))}getElement(){return this._element}toggleDebugOverlay(){this._overlay.toggleDebug()}destroy(){let t=document.getElementById(this._player.getElementId())?.querySelector("video");this._overlay!==void 0&&this._overlay.destroy(),t&&t.remove()}};var N=class{_player;_element;constructor(e){this._player=e}create(e){let t=document.createElement("audio");t.srcObject=e,t.autoplay=!0,t.muted=!1,this._element=t;let n=document.getElementById(this._player.getElementId());n!==null&&n.appendChild(this._element)}getElement(){return this._element}destroy(){let t=document.getElementById(this._player.getElementId())?.querySelector("audio");t&&t.remove()}};var U=class{_peerConnection=new RTCPeerConnection({});_channels={chat:new S(this),control:new w(this),input:new A(this),message:new M(this)};_elementId;_isDestoyed=!1;_iceHelper=new R(this);_sdpHelper=new I(this);_statsHelper=new P(this);_videoComponent;_audioComponent;constructor(e){console.log("xCloudPlayer constructing..."),this._elementId=e,this._peerConnection.addTransceiver("audio",{direction:"sendrecv"}),this._peerConnection.addTransceiver("video",{direction:"recvonly"}).setCodecPreferences(this._sdpHelper.getDefaultCodecPreferences()),this._peerConnection.ontrack=n=>{n.track.kind==="video"?(this._videoComponent=new L(this),this._videoComponent.create(n.streams[0])):n.track.kind==="audio"?(this._audioComponent=new N(this),this._audioComponent.create(n.streams[0])):console.log("[xPlayer] Detected an unknown stream type: ",n.track.kind)},console.log("xCloudPlayer constructed.")}init(){console.log("xCloudPlayer init called.")}getElementId(){return this._elementId}async createOffer(){let e=await this._peerConnection.createOffer({offerToReceiveAudio:!0,offerToReceiveVideo:!0}),t=this._sdpHelper.setLocalSDP(e);return this._peerConnection.setLocalDescription(t),t}async setRemoteSDP(e){let t=this._sdpHelper.setRemoteSDP(e);return await this._peerConnection.setRemoteDescription({type:"answer",sdp:t})}async getICECandidates(){return this._iceHelper.getCandidates()}async setRemoteIceCandidates(e){this._iceHelper.setRemoteCandidates(e)}getVideoElement(){if(this._videoComponent)return this._videoComponent.getElement()}getAudioElement(){if(this._audioComponent)return this._audioComponent.getElement()}toggleDebugOverlay(){this._videoComponent&&this._videoComponent.toggleDebugOverlay()}getStats(){return this._statsHelper}destroy(){if(this._isDestoyed===!1){this._peerConnection.close(),this._peerConnection.onconnectionstatechange&&this._peerConnection.onconnectionstatechange(new Event("connectionstatechanged"));for(let e in this._channels)this._channels[e].destroy();this._videoComponent&&this._videoComponent.destroy(),this._audioComponent&&this._audioComponent.destroy(),this._isDestoyed=!0}else console.log("Cannot destroy because the player is already destroyed.")}};function X(r){return r.brightness!==0||r.contrast!==0||Math.abs(r.gamma-1)>.001||r.saturation!==100||r.sharpen>0||r.deband>0||r.fxaa||r.toon||r.crt||r.ntsc||r.maxFps>0}var J=`#version 300 es
+in vec2 position;
+out vec2 vUV;
+void main() { vUV = position * 0.5 + 0.5; gl_Position = vec4(position, 0.0, 1.0); }`,j=`attribute vec2 position;
+varying vec2 vUV;
+void main() { vUV = position * 0.5 + 0.5; gl_Position = vec4(position, 0.0, 1.0); }`,Z=`#version 300 es
+precision mediump float;
+uniform sampler2D screenTexture;
+uniform vec2 resolution;
+uniform int filterId;
+uniform float sharpenFactor;
+uniform float brightness;
+uniform float contrast;
+uniform float gamma;
+uniform float saturation;
+uniform float deband;
+out vec4 fragColor;
+const float CAS_CONTRAST_PEAK = 0.8 * -3.0 + 8.0;
+
+vec3 sharpen(vec2 coord, vec3 e) {
+ vec2 px = 1.0 / resolution;
+ vec3 b = texture(screenTexture, coord + px * vec2(0, 1)).rgb;
+ vec3 d = texture(screenTexture, coord + px * vec2(-1, 0)).rgb;
+ vec3 f = texture(screenTexture, coord + px * vec2(1, 0)).rgb;
+ vec3 h = texture(screenTexture, coord + px * vec2(0, -1)).rgb;
+ vec3 a = texture(screenTexture, coord + px * vec2(-1, 1)).rgb;
+ vec3 c = texture(screenTexture, coord + px * vec2(1, 1)).rgb;
+ vec3 g = texture(screenTexture, coord + px * vec2(-1, -1)).rgb;
+ vec3 i = texture(screenTexture, coord + px * vec2(1, -1)).rgb;
+ if (filterId == 1) {
+ vec3 blur = ((a + c + g + i) + (b + d + f + h) * 2.0 + e * 4.0) / 16.0;
+ return e + (e - blur) * sharpenFactor / 3.0;
+ }
+ vec3 minRgb = min(min(min(d, e), min(f, b)), h) + min(min(a, c), min(g, i));
+ vec3 maxRgb = max(max(max(d, e), max(f, b)), h) + max(max(a, c), max(g, i));
+ vec3 amplify = inversesqrt(clamp(min(minRgb, 2.0 - maxRgb) / maxRgb, 0.0, 1.0));
+ vec3 weight = -(1.0 / (amplify * CAS_CONTRAST_PEAK));
+ vec3 outColor = clamp(((b + d + f + h) * weight + e) / (4.0 * weight + 1.0), 0.0, 1.0);
+ return mix(e, outColor, sharpenFactor / 2.0);
+}
+
+float ign(vec2 p) { return fract(52.9829189 * fract(dot(p, vec2(0.06711056, 0.00583715)))); }
+
+void main() {
+ vec2 uv = gl_FragCoord.xy / resolution;
+ vec3 color = texture(screenTexture, uv).rgb;
+ if (sharpenFactor > 0.0) color = sharpen(uv, color);
+ color = clamp(color + brightness, 0.0, 1.0);
+ color = (color - 0.5) * clamp(contrast + 1.0, 0.5, 2.0) + 0.5;
+ color = mix(vec3(dot(color, vec3(0.2126, 0.7152, 0.0722))), color, saturation);
+ color = pow(max(color, 0.0), vec3(1.0 / gamma));
+ if (deband > 0.0) color += vec3(((ign(gl_FragCoord.xy) + ign(gl_FragCoord.xy + vec2(11.0, 17.0))) - 1.0) * (deband / 255.0));
+ fragColor = vec4(color, 1.0);
+}`,ee=`precision highp float;
+#define FXAA_MIN_REDUCE (1.0 / 128.0)
+#define FXAA_MUL_REDUCE (1.0 / 8.0)
+#define MAX_SPAN 8.0
+uniform sampler2D screenTexture;
+uniform vec2 resolution;
+const vec3 luma = vec3(0.299, 0.587, 0.114);
+void main() {
+ vec2 invResolution = 1.0 / resolution;
+ vec3 rgbNW = texture2D(screenTexture, (gl_FragCoord.xy + vec2(-1.0, -1.0)) * invResolution).rgb;
+ vec3 rgbNE = texture2D(screenTexture, (gl_FragCoord.xy + vec2( 1.0, -1.0)) * invResolution).rgb;
+ vec3 rgbSW = texture2D(screenTexture, (gl_FragCoord.xy + vec2(-1.0, 1.0)) * invResolution).rgb;
+ vec3 rgbSE = texture2D(screenTexture, (gl_FragCoord.xy + vec2( 1.0, 1.0)) * invResolution).rgb;
+ vec3 rgbM = texture2D(screenTexture, gl_FragCoord.xy * invResolution).rgb;
+ float lumaNW = dot(rgbNW, luma);
+ float lumaNE = dot(rgbNE, luma);
+ float lumaSW = dot(rgbSW, luma);
+ float lumaSE = dot(rgbSE, luma);
+ float lumaM = dot(rgbM, luma);
+ float lumaMin = min(lumaM, min(min(lumaNW, lumaNE), min(lumaSW, lumaSE)));
+ float lumaMax = max(lumaM, max(max(lumaNW, lumaNE), max(lumaSW, lumaSE)));
+ vec2 dir;
+ dir.x = -((lumaNW + lumaNE) - (lumaSW + lumaSE));
+ dir.y = ((lumaNW + lumaSW) - (lumaNE + lumaSE));
+ float dirReduce = max((lumaNW + lumaNE + lumaSW + lumaSE) * 0.25 * FXAA_MUL_REDUCE, FXAA_MIN_REDUCE);
+ float minDirFactor = 1.0 / (min(abs(dir.x), abs(dir.y)) + dirReduce);
+ dir = clamp(dir * minDirFactor, vec2(-MAX_SPAN), vec2(MAX_SPAN)) * invResolution;
+ vec4 rgbA = 0.5 * (
+ texture2D(screenTexture, gl_FragCoord.xy * invResolution + dir * (1.0 / 3.0 - 0.5)) +
+ texture2D(screenTexture, gl_FragCoord.xy * invResolution + dir * (2.0 / 3.0 - 0.5)));
+ vec4 rgbB = rgbA * 0.5 + 0.25 * (
+ texture2D(screenTexture, gl_FragCoord.xy * invResolution + dir * -0.5) +
+ texture2D(screenTexture, gl_FragCoord.xy * invResolution + dir * 0.5));
+ float lumaB = dot(rgbB, vec4(luma, 0.0));
+ gl_FragColor = lumaB < lumaMin || lumaB > lumaMax ? rgbA : rgbB;
+}`,te=`precision highp float;
+uniform sampler2D screenTexture;
+uniform vec2 resolution;
+void main() {
+ vec2 uv = gl_FragCoord.xy / resolution;
+ float edgeThreshold = 0.2;
+ vec2 offset = vec2(1.0) / resolution;
+ vec3 colorCenter = texture2D(screenTexture, uv).rgb;
+ vec3 colorLeft = texture2D(screenTexture, uv - vec2(offset.x, 0.0)).rgb;
+ vec3 colorRight = texture2D(screenTexture, uv + vec2(offset.x, 0.0)).rgb;
+ vec3 colorUp = texture2D(screenTexture, uv - vec2(0.0, offset.y)).rgb;
+ vec3 colorDown = texture2D(screenTexture, uv + vec2(0.0, offset.y)).rgb;
+ float diffHorizontal = length(colorRight - colorLeft);
+ float diffVertical = length(colorUp - colorDown);
+ float edgeFactor = step(edgeThreshold, diffHorizontal + diffVertical);
+ vec3 outlineColor = mix(colorCenter, vec3(0.0), edgeFactor);
+ gl_FragColor = vec4(outlineColor, 1.0);
+}`,ne=`precision highp float;
+#define CA_AMOUNT 1.0025
+#define SCANLINE_INTENSITY_X 0.125
+#define SCANLINE_INTENSITY_Y 0.375
+#define SCANLINE_SIZE 1024.0
+uniform sampler2D screenTexture;
+varying vec2 vUV;
+void main() {
+ vec4 finalColor = texture2D(screenTexture, vUV);
+ finalColor.rgb = vec3(
+ texture2D(screenTexture, (vUV - 0.5) * CA_AMOUNT + 0.5).r,
+ finalColor.g,
+ texture2D(screenTexture, (vUV - 0.5) / CA_AMOUNT + 0.5).b
+ );
+ float scanlineX = abs(sin(vUV.x * SCANLINE_SIZE) * 0.5 * SCANLINE_INTENSITY_X);
+ float scanlineY = abs(sin(vUV.y * SCANLINE_SIZE) * 0.5 * SCANLINE_INTENSITY_Y);
+ gl_FragColor = vec4(mix(finalColor.rgb, vec3(0.0), scanlineX + scanlineY), finalColor.a);
+}`,ie=`precision highp float;
+#define PI 3.14159265
+#define SCANLINE_INTENSITY 0.35
+#define CHROMA_OFFSET 0.005
+#define BLUR_RADIUS 0.002
+#define WARP_AMOUNT 0.01
+#define SCANLINE_DARKEN 0.5
+uniform sampler2D screenTexture;
+uniform int FrameCount;
+uniform vec2 TextureSize;
+uniform vec2 resolution;
+varying vec2 vUV;
+const mat3 yiq_mat = mat3(
+ 0.299, 0.587, 0.114,
+ 0.596, -0.275, -0.321,
+ 0.212, -0.523, 0.311
+);
+const mat3 yiq2rgb_mat = mat3(
+ 1.0, 0.956, 0.621,
+ 1.0, -0.272, -0.647,
+ 1.0, -1.106, 1.705
+);
+vec3 applyNTSC(vec2 uv) {
+ vec3 col = texture2D(screenTexture, uv).rgb;
+ vec3 yiq = col * yiq_mat;
+ float chromaPhase = PI * (mod(uv.y * TextureSize.y, 2.0) + float(FrameCount));
+ yiq.y *= cos(chromaPhase * 0.5);
+ yiq.z *= sin(chromaPhase * 0.5);
+ vec3 rgb = yiq * yiq2rgb_mat;
+ vec3 finalColor;
+ finalColor.r = texture2D(screenTexture, uv + vec2(CHROMA_OFFSET, 0.0)).r;
+ finalColor.g = texture2D(screenTexture, uv + vec2(0.0, BLUR_RADIUS)).g;
+ finalColor.b = texture2D(screenTexture, uv - vec2(CHROMA_OFFSET, 0.0)).b;
+ return finalColor;
+}
+vec3 applyScanlines(vec2 uv) {
+ vec3 col = texture2D(screenTexture, uv).rgb;
+ float scanline = abs(sin(uv.y * resolution.y * 2.0)) * SCANLINE_INTENSITY;
+ col *= 1.0 - (scanline * SCANLINE_DARKEN);
+ return col;
+}
+vec2 applyWarp(vec2 uv) {
+ uv = uv * 2.0 - 1.0;
+ float r = sqrt(uv.x * uv.x + uv.y * uv.y);
+ uv += uv * (r * r) * WARP_AMOUNT;
+ return uv * 0.5 + 0.5;
+}
+void main() {
+ vec2 warpedUV = applyWarp(vUV);
+ vec3 ntscColor = applyNTSC(warpedUV);
+ vec3 scanlineColor = applyScanlines(warpedUV);
+ vec3 finalColor = mix(ntscColor, scanlineColor, 0.7);
+ gl_FragColor = vec4(finalColor, 1.0);
+}`,H=class{constructor(e,t){this.video=e;this.effects=t;this.canvas=document.createElement("canvas"),Object.assign(this.canvas.style,{position:"absolute",inset:"0",width:"100%",height:"100%",objectFit:"contain",zIndex:"1"}),e.parentElement?.appendChild(this.canvas);let n=this.canvas.getContext("webgl2",{alpha:!1,antialias:!1,depth:!1,desynchronized:!0,powerPreference:"high-performance"});if(n===null)throw new Error("WebGL2 unavailable");this.gl=n;let i=n.createBuffer();n.bindBuffer(n.ARRAY_BUFFER,i),n.bufferData(n.ARRAY_BUFFER,new Float32Array([-1,-1,3,-1,-1,3]),n.STATIC_DRAW),this.passes.set("base",this.compile(J,Z,["screenTexture","resolution","filterId","sharpenFactor","brightness","contrast","gamma","saturation","deband"])),this.videoTexture=this.texture(),this.schedule()}canvas;gl;passes=new Map;videoTexture;targets=[];width=0;height=0;frame=0;lastDraw=0;stopped=!1;idle=!1;pending=!1;update(e){this.effects=e}pause(){this.idle||this.stopped||(this.idle=!0,this.canvas.style.display="none")}resume(e){this.effects=e,!(!this.idle||this.stopped)&&(this.idle=!1,this.canvas.style.display="",this.schedule())}schedule(){this.pending||(this.pending=!0,this.video.requestVideoFrameCallback(this.onFrame))}compile(e,t,n){let i=this.gl,a=(o,d)=>{let u=i.createShader(o);if(i.shaderSource(u,d),i.compileShader(u),!i.getShaderParameter(u,i.COMPILE_STATUS))throw new Error(`shader: ${i.getShaderInfoLog(u)}`);return u},s=i.createProgram();if(i.attachShader(s,a(i.VERTEX_SHADER,e)),i.attachShader(s,a(i.FRAGMENT_SHADER,t)),i.bindAttribLocation(s,0,"position"),i.linkProgram(s),!i.getProgramParameter(s,i.LINK_STATUS))throw new Error(`program: ${i.getProgramInfoLog(s)}`);return{program:s,uniforms:new Map(n.map(o=>[o,i.getUniformLocation(s,o)]))}}extra(e){let t=this.passes.get(e);if(t===void 0){let n={fxaa:ee,toon:te,crt:ne,ntsc:ie}[e];t=this.compile(j,n,["screenTexture","resolution","TextureSize","FrameCount"]),this.passes.set(e,t)}return t}texture(){let e=this.gl,t=e.createTexture();return e.bindTexture(e.TEXTURE_2D,t),e.texParameteri(e.TEXTURE_2D,e.TEXTURE_MIN_FILTER,e.LINEAR),e.texParameteri(e.TEXTURE_2D,e.TEXTURE_MAG_FILTER,e.LINEAR),e.texParameteri(e.TEXTURE_2D,e.TEXTURE_WRAP_S,e.CLAMP_TO_EDGE),e.texParameteri(e.TEXTURE_2D,e.TEXTURE_WRAP_T,e.CLAMP_TO_EDGE),t}resize(e,t){let n=this.gl;this.width=e,this.height=t,this.canvas.width=e,this.canvas.height=t;for(let i of this.targets)n.deleteFramebuffer(i.framebuffer),n.deleteTexture(i.texture);this.targets=[0,1].map(()=>{let i=this.texture();n.texImage2D(n.TEXTURE_2D,0,n.RGBA,e,t,0,n.RGBA,n.UNSIGNED_BYTE,null);let a=n.createFramebuffer();return n.bindFramebuffer(n.FRAMEBUFFER,a),n.framebufferTexture2D(n.FRAMEBUFFER,n.COLOR_ATTACHMENT0,n.TEXTURE_2D,i,0),{framebuffer:a,texture:i}}),n.bindFramebuffer(n.FRAMEBUFFER,null)}onFrame=e=>{if(this.pending=!1,this.stopped||this.idle)return;this.schedule();let t=this.effects.maxFps;if(!(t>0&&e-this.lastDraw<1e3/t-2)){this.lastDraw=e;try{this.draw()}catch(n){window.DDCloud?.log(`effects: ${String(n).slice(0,200)}`),this.destroy()}}};draw(){let e=this.gl,t=this.effects,n=this.video.videoWidth,i=this.video.videoHeight;if(n===0||i===0)return;(n!==this.width||i!==this.height)&&this.resize(n,i),this.frame++,e.activeTexture(e.TEXTURE0),e.bindTexture(e.TEXTURE_2D,this.videoTexture),e.pixelStorei(e.UNPACK_FLIP_Y_WEBGL,!0),e.texImage2D(e.TEXTURE_2D,0,e.RGBA,e.RGBA,e.UNSIGNED_BYTE,this.video);let a=["fxaa","toon","crt","ntsc"].filter(d=>t[d]);e.viewport(0,0,n,i),e.enableVertexAttribArray(0),e.vertexAttribPointer(0,2,e.FLOAT,!1,0,0);let s=this.passes.get("base");e.bindFramebuffer(e.FRAMEBUFFER,a.length>0?this.targets[0].framebuffer:null),e.useProgram(s.program);let o=s.uniforms;e.uniform1i(o.get("screenTexture"),0),e.uniform2f(o.get("resolution"),n,i),e.uniform1i(o.get("filterId"),t.sharpenMode==="usm"?1:2),e.uniform1f(o.get("sharpenFactor"),t.sharpen),e.uniform1f(o.get("brightness"),t.brightness/100),e.uniform1f(o.get("contrast"),t.contrast/100),e.uniform1f(o.get("gamma"),Math.min(5,Math.max(.1,t.gamma))),e.uniform1f(o.get("saturation"),t.saturation/100),e.uniform1f(o.get("deband"),t.deband),e.drawArrays(e.TRIANGLES,0,3),a.forEach((d,u)=>{let h=this.extra(d),g=this.targets[u%2],p=u===a.length-1;e.bindFramebuffer(e.FRAMEBUFFER,p?null:this.targets[(u+1)%2].framebuffer),e.useProgram(h.program),e.bindTexture(e.TEXTURE_2D,g.texture);let c=h.uniforms;e.uniform1i(c.get("screenTexture")??null,0),e.uniform2f(c.get("resolution")??null,n,i),e.uniform2f(c.get("TextureSize")??null,n,i),e.uniform1i(c.get("FrameCount")??null,this.frame),e.drawArrays(e.TRIANGLES,0,3)})}destroy(){this.stopped=!0,this.canvas.remove(),this.gl.getExtension("WEBGL_lose_context")?.loseContext()}};var V=class{constructor(e){this.pc=e;this.element=document.createElement("div"),Object.assign(this.element.style,{position:"absolute",top:"8px",right:"8px",zIndex:"3",padding:"4px 8px",borderRadius:"6px",background:"rgba(0,0,0,0.55)",color:"#e8e8e8",font:"12px monospace",lineHeight:"16px",pointerEvents:"none",display:"none",whiteSpace:"pre",textAlign:"right"}),document.body.appendChild(this.element)}element;timer;level=0;vertical=!1;left=!1;server="";last={bytes:0,time:0,decode:0,frames:0,lost:0,received:0};show(e,t,n,i=!1){this.level=e,this.vertical=t,this.server=n,this.left=i,Object.assign(this.element.style,i?{left:"8px",right:"",textAlign:"left"}:{left:"",right:"8px",textAlign:"right"}),this.element.style.display=e>0?"block":"none",e>0&&this.timer===void 0&&(this.timer=setInterval(()=>this.update(),1e3),this.update()),e===0&&this.timer!==void 0&&(clearInterval(this.timer),this.timer=void 0)}async update(){let e=await this.pc.getStats(),t,n,i=new Map;if(e.forEach(c=>{c.type==="inbound-rtp"&&c.kind==="video"?t=c:c.type==="candidate-pair"&&c.state==="succeeded"&&c.nominated?n=c:c.type==="codec"&&i.set(c.id,c)}),t===void 0)return;let a=this.last.time>0?(t.timestamp-this.last.time)/1e3:0,s=a>0?(t.bytesReceived-this.last.bytes)*8/a/1e6:0,o=(t.framesDecoded??0)-this.last.frames,d=o>0?((t.totalDecodeTime??0)-this.last.decode)*1e3/o:0,u=(t.packetsLost??0)-this.last.lost,h=(t.packetsReceived??0)-this.last.received,g=u+h>0?u*100/(u+h):0;this.last={bytes:t.bytesReceived,time:t.timestamp,decode:t.totalDecodeTime??0,frames:t.framesDecoded??0,lost:t.packetsLost??0,received:t.packetsReceived??0};let p=["web",`${Math.round(t.framesPerSecond??0)} fps`];if(this.level>=2&&(n?.currentRoundTripTime!==void 0&&p.push(`ping ${Math.round(n.currentRoundTripTime*1e3)} ms`),p.push(`${t.frameWidth??"?"}x${t.frameHeight??"?"}`)),this.level>=3&&p.push(`${s.toFixed(1)} Mb/s`,`decode ${d.toFixed(1)} ms`,`loss ${g.toFixed(1)}%`),this.level>=4){let c=i.get(t.codecId);c&&p.push(String(c.mimeType??"").replace("video/","")),p.push(`jitter ${Math.round((t.jitter??0)*1e3)} ms`,`dropped ${t.framesDropped??0}`),this.server&&p.push(this.server)}this.element.textContent=p.join(this.vertical?`
+`:" \xB7 ")}destroy(){this.show(0,this.vertical,this.server,this.left),this.element.remove()}};function z(r,e){let t=e==="high"?"4d":e==="low"?"420":"42e",n=[];for(let d of r.matchAll(/a=fmtp:(\d+).*profile-level-id=([0-9a-f]{6})/g))d[2].startsWith(t)&&n.push(d[1]);if(n.length===0)return r;let i=r.split(`\r
+`),a=i.findIndex(d=>d.startsWith("m=video"));if(a<0)return r;let s=i[a].trim().split(" "),o=s.slice(3).filter(d=>!n.includes(d));return i[a]=s.slice(0,3).concat(n,o).join(" "),i.join(`\r
+`)}function K(r,e){let t=r.split(`\r
+`),n=t.findIndex(a=>a.startsWith("m=video"));if(n<0)return r;let i=`b=AS:${e}`;for(let a=n+1;a<=t.length;a++){let s=t[a];if(s===void 0||s.startsWith("m=")){t.splice(a,0,i);break}if(!(s.startsWith("i=")||s.startsWith("c="))){if(s.startsWith("b=AS:")){t[a]=i;break}t.splice(a,0,i);break}}return t.join(`\r
+`)}var m=JSON.parse(DDCloud.settings()),ae=RTCPeerConnection.prototype.setLocalDescription;RTCPeerConnection.prototype.setLocalDescription=function(r){return r?.sdp&&((m.codec==="high"||m.codec==="normal"||m.codec==="low")&&(r.sdp=z(r.sdp,m.codec)),m.bitrateMbps>0&&(r.sdp=K(r.sdp,m.bitrateMbps*1e3))),ae.call(this,r)};window.__ddRumble=m.rumble/100;window.addEventListener("error",r=>DDCloud.log(`error: ${r.message} @${r.lineno}`));window.addEventListener("unhandledrejection",r=>DDCloud.log(`rejection: ${String(r.reason).slice(0,200)}`));window.addEventListener("gamepadconnected",r=>DDCloud.log(`controller: ${r.gamepad.id.slice(0,60)} mapping=${r.gamepad.mapping||"none"} buttons=${r.gamepad.buttons.length} axes=${r.gamepad.axes.length}`));var W=new Map,re=1;window.__ddResolve=(r,e,t)=>{let n=W.get(r);W.delete(r),n&&n({status:e,body:t})};function C(r,e=""){return new Promise(t=>{let n=re++;W.set(n,t),DDCloud.call(n,r,typeof e=="string"?e:JSON.stringify(e))})}var B=r=>new Promise(e=>setTimeout(e,r)),se=r=>r.status>=200&&r.status<300;function x(r){let e=document.getElementById("status");e&&(e.textContent=r),DDCloud.status(r)}var T=class extends Error{constructor(t,n){super(t);this.stage=t;this.detail=n}};function k(r,e){if(!se(e))throw new T(r,`${e.status} ${e.body.slice(0,300)}`);return e}function oe(r,e){return new Promise(t=>{if(r.iceGatheringState==="complete")return t();let n=setTimeout(t,e);r.addEventListener("icegatheringstatechange",()=>{r.iceGatheringState==="complete"&&(clearTimeout(n),t())})})}async function de(){x("Starting the game on Xbox Cloud Gaming\u2026"),k("start",await C("start"));let r=!1,e=0;for(;;){let l=k("state",await C("state")),f=JSON.parse(l.body||"{}");if(f.state==="Provisioned")break;if(f.state==="Failed")throw new T("provision",JSON.stringify(f.errorDetails??{}).slice(0,300));f.state==="ReadyToConnect"&&!r?(r=!0,x("Signing in to the cloud console\u2026"),k("connect",await C("connect"))):f.state==="WaitingForResources"?x(`In the queue for a cloud console\u2026 ${Math.round(e/1e3)} s`):x(`Getting a cloud console ready\u2026 ${Math.round(e/1e3)} s`),await B(1e3),e+=1e3}x("Connecting\u2026");let t=new U("player");t.init();let n=t._peerConnection,i=t._channels.input,a=i.queueMetadataFrame.bind(i);i.queueMetadataFrame=l=>{if(m.preventDrops){let f=l.frameRenderedTimeMs-l.frameDecodedTimeMs;l.frameDecodedTimeMs=l.frameSubmittedTimeMs+10,l.frameRenderedTimeMs=l.frameDecodedTimeMs+f}a(l)};let s,o,d=()=>{if(!(o===void 0||s===void 0&&m.volume===100)){if(s===void 0){let l=new AudioContext;s=l.createGain(),l.createMediaStreamSource(o).connect(s).connect(l.destination);let f=document.querySelector("#player audio");f&&(f.muted=!0)}s.gain.value=m.volume/100}};n.addEventListener("track",l=>{l.track.kind==="audio"&&(o=l.streams[0],d())});let u=new V(n),h,g=()=>{let l=t.getVideoElement();if(l!==void 0){if(!X(m)){h?.pause();return}if(h===void 0)try{h=new H(l,m)}catch(f){DDCloud.log(`effects unavailable: ${String(f).slice(0,200)}`)}else h.resume(m)}};window.__ddApply=l=>{m=JSON.parse(l),window.__ddRumble=m.rumble/100,u.show(m.statsLevel,m.statsLayout==="vertical",m.server,m.statsCorner==="left"),d(),g()},n.addEventListener("connectionstatechange",()=>{n.connectionState==="connected"?(x(""),DDCloud.playing(),u.show(m.statsLevel,m.statsLayout==="vertical",m.server,m.statsCorner==="left"),setTimeout(g,500)):n.connectionState==="failed"?DDCloud.failed("webrtc","connection failed"):n.connectionState==="disconnected"&&x("Connection lost\u2026")});let p=await t.createOffer(),c=k("sdp",await C("sdp",p.sdp??"")),O=JSON.parse(JSON.parse(c.body).exchangeResponse);if(O.status&&O.status!=="success")throw new T("sdp-answer",JSON.stringify(O).slice(0,300));await t.setRemoteSDP(O.sdp),await oe(n,3e3);let Y=(await t.getICECandidates()).map(l=>JSON.stringify({candidate:l.candidate,sdpMid:l.sdpMid,sdpMLineIndex:l.sdpMLineIndex,usernameFragment:l.usernameFragment})),$=k("ice",await C("ice",Y));t.setRemoteIceCandidates(JSON.parse(JSON.parse($.body).exchangeResponse));let q=Date.now();for(;!t._channels.message._handshakeCompleted&&Date.now()-q<2e4;)await B(100);DDCloud.log(`handshake ${t._channels.message._handshakeCompleted?"done":"missing"} after ${Date.now()-q} ms`),await B(300);let b=new v(0,{enable_keyboard:!1});b.attach(t),DDCloud.log(`controllers at attach: ${Array.from(navigator.getGamepads()).filter(Boolean).length}`),window.__ddNexus=()=>{b.sendButtonState("Nexus",1),setTimeout(()=>b.sendButtonState("Nexus",0),200)},window.__ddButton=(l,f)=>b.sendButtonState(l,f),window.__ddInput=l=>{l?b.onWindowFocus():b.onWindowBlur()},setInterval(()=>{C("keepalive")},3e4),window.__ddStop=()=>{try{h?.destroy(),u.destroy(),b.detach(),t.destroy()}catch{}}}de().catch(r=>{r instanceof T?DDCloud.failed(r.stage,r.detail):DDCloud.failed("page",String(r).slice(0,300))});})();
diff --git a/app/src/main/cpp/waylandcomp/CMakeLists.txt b/app/src/main/cpp/waylandcomp/CMakeLists.txt
index f8eb31291..c2bfc8643 100644
--- a/app/src/main/cpp/waylandcomp/CMakeLists.txt
+++ b/app/src/main/cpp/waylandcomp/CMakeLists.txt
@@ -95,4 +95,5 @@ target_link_libraries(droiddeckwayland
dl
log
android
+ nativewindow
)
diff --git a/app/src/main/cpp/waylandcomp/src/compositor.c b/app/src/main/cpp/waylandcomp/src/compositor.c
index 1be637a7b..562a83a75 100644
--- a/app/src/main/cpp/waylandcomp/src/compositor.c
+++ b/app/src/main/cpp/waylandcomp/src/compositor.c
@@ -16,6 +16,7 @@
#include
#include
#include
+#include
#include
#include "xdg-shell-server-protocol.h"
@@ -1845,7 +1846,121 @@ static void hdr_count_copied(const struct draw_list *dl, const char *reason) {
}
}
+/*
+ * Stream mode (the Xbox Cloud Gaming native player, in its own process): no Wayland clients, the scene is one
+ * picture handed over as AHardwareBuffers (ImageReader images of the decoded video). Each buffer is imported once
+ * and kept while the reader cycles through its few buffers; the newest frame is the scene, so the screen effects
+ * and frame generation apply to it as to a game.
+ */
+#define STREAM_SLOTS 6
+static pthread_mutex_t g_stream_lock = PTHREAD_MUTEX_INITIALIZER;
+static atomic_int g_stream_on;
+static AHardwareBuffer *g_stream_pending;
+static struct { AHardwareBuffer *ahb; struct vkp_image *img; uint64_t used; } g_stream_slots[STREAM_SLOTS];
+static struct vkp_image *g_stream_img;
+static uint64_t g_stream_seq;
+static atomic_uint g_stream_frames;
+
+static void stream_forget_all(void) {
+ vkp_wait_idle();
+ for (int i = 0; i < STREAM_SLOTS; i++) {
+ if (g_stream_slots[i].img) vkp_image_destroy(g_stream_slots[i].img);
+ if (g_stream_slots[i].ahb) AHardwareBuffer_release(g_stream_slots[i].ahb);
+ g_stream_slots[i].img = NULL; g_stream_slots[i].ahb = NULL; g_stream_slots[i].used = 0;
+ }
+ g_stream_img = NULL;
+}
+
+/* Compositor thread: the newest handed-over buffer becomes the scene picture. */
+static void stream_take(void) {
+ pthread_mutex_lock(&g_stream_lock);
+ AHardwareBuffer *ahb = g_stream_pending;
+ g_stream_pending = NULL;
+ pthread_mutex_unlock(&g_stream_lock);
+ if (!ahb) return;
+ if (!atomic_load(&g_stream_on)) { AHardwareBuffer_release(ahb); return; }
+ int slot = -1, oldest = 0;
+ for (int i = 0; i < STREAM_SLOTS; i++) {
+ if (g_stream_slots[i].ahb == ahb) { slot = i; break; }
+ if (g_stream_slots[i].used < g_stream_slots[oldest].used) oldest = i;
+ }
+ if (slot >= 0) {
+ AHardwareBuffer_release(ahb); /* the slot keeps its own reference */
+ } else {
+ struct vkp_image *img = vkp_image_from_ahb(ahb);
+ if (!img) {
+ static int said;
+ if (!said) { said = 1; droiddeck_log("stream", "a decoded frame could not be imported: nothing to show"); }
+ AHardwareBuffer_release(ahb);
+ return;
+ }
+ droiddeck_log("stream", "decoded frame buffer imported (%dx%d)", vkp_image_width(img), vkp_image_height(img));
+ slot = oldest;
+ if (g_stream_slots[slot].img) {
+ vkp_wait_idle(); /* the GPU may still read the picture being replaced */
+ if (g_stream_img == g_stream_slots[slot].img) g_stream_img = NULL;
+ vkp_image_destroy(g_stream_slots[slot].img);
+ }
+ if (g_stream_slots[slot].ahb) AHardwareBuffer_release(g_stream_slots[slot].ahb);
+ g_stream_slots[slot].ahb = ahb;
+ g_stream_slots[slot].img = img;
+ }
+ g_stream_slots[slot].used = ++g_stream_seq;
+ g_stream_img = g_stream_slots[slot].img;
+ atomic_fetch_add(&g_stream_frames, 1);
+ schedule_render();
+}
+
+static void render_stream(void) {
+ g_dirty = 0;
+ struct vkp_image *img = g_stream_img;
+ if (!img) return;
+ int w = vkp_image_width(img), h = vkp_image_height(img);
+ if (w != g_scene_w || h != g_scene_h) {
+ droiddeck_log("stream", "picture %dx%d", w, h);
+ g_scene_w = w;
+ g_scene_h = h;
+ }
+ struct vkp_draw d = {.img = img, .sx = 0, .sy = 0, .sw = (float)w, .sh = (float)h, .dx = 0, .dy = 0, .dw = w, .dh = h, .blend = 0};
+ vkp_apply_window_request();
+ sc_layer_hide();
+ int r = vkp_render(w, h, &d, 1);
+ if (r == 0) {
+ g_stat_frames++;
+ static int first;
+ if (!first) { first = 1; droiddeck_log("stream", "first stream frame presented"); }
+ } else {
+ static int failed;
+ if (failed++ % 600 == 0) droiddeck_log("stream", "presenting a stream frame failed (%d, %d times)", r, failed);
+ }
+}
+
+void droiddeck_stream_set(int on) {
+ atomic_store(&g_stream_on, on ? 1 : 0);
+ if (g_input_pipe[1] < 0) return;
+ struct input_msg m = { 21, on ? 1 : 0, 0, 0, 0 };
+ ssize_t n = write(g_input_pipe[1], &m, sizeof(m));
+ (void)n;
+}
+
+/* Any thread: hands over one decoded frame (acquires its own reference). */
+void droiddeck_stream_frame(AHardwareBuffer *ahb) {
+ if (!ahb || !atomic_load(&g_stream_on) || g_input_pipe[1] < 0) return;
+ AHardwareBuffer_acquire(ahb);
+ pthread_mutex_lock(&g_stream_lock);
+ AHardwareBuffer *dropped = g_stream_pending;
+ g_stream_pending = ahb;
+ pthread_mutex_unlock(&g_stream_lock);
+ if (dropped) { AHardwareBuffer_release(dropped); return; } /* a wake-up is already queued */
+ struct input_msg m = { 20, 0, 0, 0, 0 };
+ ssize_t n = write(g_input_pipe[1], &m, sizeof(m));
+ (void)n;
+}
+
+unsigned droiddeck_stream_frames(void) { return atomic_load(&g_stream_frames); }
+
static void render_scene(void) {
+ if (atomic_load(&g_stream_on)) { render_stream(); return; }
struct draw_list dl = {0};
struct surface *s;
int w, h;
@@ -3005,6 +3120,8 @@ static int on_input_readable(int fd, uint32_t mask, void *data) {
break;
case 10: break;
case 11: axis_event(m.p1, m.p2); break;
+ case 20: stream_take(); break;
+ case 21: if (!m.p1) stream_forget_all(); schedule_render(); break;
default: deliver_pointer(&m); break;
}
}
diff --git a/app/src/main/cpp/waylandcomp/src/vk_loader.h b/app/src/main/cpp/waylandcomp/src/vk_loader.h
index e10950d0d..54cb88881 100644
--- a/app/src/main/cpp/waylandcomp/src/vk_loader.h
+++ b/app/src/main/cpp/waylandcomp/src/vk_loader.h
@@ -21,7 +21,7 @@
X(BindImageMemory) X(DestroyImage) X(FreeMemory) X(AcquireNextImageKHR) \
X(ResetCommandBuffer) X(BeginCommandBuffer) X(CmdPipelineBarrier) X(CmdBlitImage) \
X(EndCommandBuffer) X(ResetFences) X(QueueSubmit) X(QueuePresentKHR) X(WaitForFences) \
- X(QueueWaitIdle) X(GetMemoryFdPropertiesKHR) \
+ X(QueueWaitIdle) X(GetMemoryFdPropertiesKHR) X(GetAndroidHardwareBufferPropertiesANDROID) \
X(GetImageSubresourceLayout) X(MapMemory) X(UnmapMemory) X(CmdClearColorImage) \
\
X(CreateShaderModule) X(DestroyShaderModule) X(CreatePipelineLayout) X(DestroyPipelineLayout) \
diff --git a/app/src/main/cpp/waylandcomp/src/vk_present.c b/app/src/main/cpp/waylandcomp/src/vk_present.c
index d7f1c9903..c917afbba 100644
--- a/app/src/main/cpp/waylandcomp/src/vk_present.c
+++ b/app/src/main/cpp/waylandcomp/src/vk_present.c
@@ -19,6 +19,7 @@
#include
#include
#include
+#include
#define TAG "DroidDeckWayland"
#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, TAG, __VA_ARGS__)
@@ -39,6 +40,7 @@ struct vkp_image {
};
static ANativeWindow *g_window;
+static int g_ahb_import;
static char *g_driver_path, *g_library_name, *g_native_lib_dir;
static int g_dev_state;
static VkInstance g_inst;
@@ -459,17 +461,19 @@ static int dev_init(void) {
}
g_vk.GetPhysicalDeviceMemoryProperties(g_pd, &g_memprops);
- const char *dev_exts[7] = {VK_KHR_SWAPCHAIN_EXTENSION_NAME, "VK_KHR_external_memory_fd",
- "VK_EXT_external_memory_dma_buf", "VK_EXT_image_drm_format_modifier",
- "VK_KHR_image_format_list", NULL, NULL};
- uint32_t n_dev_exts = 5;
+ static const char *const import_exts[4] = {"VK_KHR_external_memory_fd", "VK_EXT_external_memory_dma_buf",
+ "VK_EXT_image_drm_format_modifier", "VK_KHR_image_format_list"};
+ const char *dev_exts[9] = {VK_KHR_SWAPCHAIN_EXTENSION_NAME};
+ uint32_t n_dev_exts = 1;
uint32_t ne = 0;
g_vk.EnumerateDeviceExtensionProperties(g_pd, NULL, &ne, NULL);
VkExtensionProperties *exts = calloc(ne ? ne : 1, sizeof(*exts));
g_vk.EnumerateDeviceExtensionProperties(g_pd, NULL, &ne, exts);
- for (unsigned i = 0; i < 5; i++)
- if (!has_ext(exts, ne, dev_exts[i]))
- LOGE("present: driver MISSING %s (dmabuf import will fail)", dev_exts[i]);
+ /* Only what the driver has: the system driver lacks the dma-buf ones, which the cloud player never needs. */
+ for (unsigned i = 0; i < 4; i++) {
+ if (has_ext(exts, ne, import_exts[i])) dev_exts[n_dev_exts++] = import_exts[i];
+ else LOGE("present: driver MISSING %s (dmabuf import will fail)", import_exts[i]);
+ }
int want_hdr_md = 0;
if (droiddeck_color_requested()) {
want_hdr_md = has_ext(exts, ne, VK_EXT_HDR_METADATA_EXTENSION_NAME);
@@ -477,6 +481,13 @@ static int dev_init(void) {
}
const int want_sem_fd = has_ext(exts, ne, VK_KHR_EXTERNAL_SEMAPHORE_FD_EXTENSION_NAME);
if (want_sem_fd) dev_exts[n_dev_exts++] = VK_KHR_EXTERNAL_SEMAPHORE_FD_EXTENSION_NAME;
+ /* The cloud player's decoded frames arrive as AHardwareBuffers (vkp_image_from_ahb). */
+ g_ahb_import = has_ext(exts, ne, VK_ANDROID_EXTERNAL_MEMORY_ANDROID_HARDWARE_BUFFER_EXTENSION_NAME) &&
+ has_ext(exts, ne, VK_EXT_QUEUE_FAMILY_FOREIGN_EXTENSION_NAME);
+ if (g_ahb_import) {
+ dev_exts[n_dev_exts++] = VK_ANDROID_EXTERNAL_MEMORY_ANDROID_HARDWARE_BUFFER_EXTENSION_NAME;
+ dev_exts[n_dev_exts++] = VK_EXT_QUEUE_FAMILY_FOREIGN_EXTENSION_NAME;
+ }
free(exts);
float prio = 1.0f;
@@ -987,6 +998,60 @@ struct vkp_image *vkp_image_import_dmabuf(int fd, uint32_t drm_format, uint64_t
return img;
}
+void vkp_wait_idle(void) {
+ if (g_dev && g_vk.DeviceWaitIdle) g_vk.DeviceWaitIdle(g_dev);
+}
+
+struct vkp_image *vkp_image_from_ahb(AHardwareBuffer *ahb) {
+ if (!ahb || dev_init() != 0) return NULL;
+ if (!g_ahb_import || !g_vk.GetAndroidHardwareBufferPropertiesANDROID) {
+ static int said;
+ if (!said) { said = 1; LOGE("ahb: the driver cannot import AHardwareBuffers (VK_ANDROID_external_memory_android_hardware_buffer)"); }
+ return NULL;
+ }
+ AHardwareBuffer_Desc desc;
+ AHardwareBuffer_describe(ahb, &desc);
+ VkAndroidHardwareBufferFormatPropertiesANDROID fp = {
+ .sType = VK_STRUCTURE_TYPE_ANDROID_HARDWARE_BUFFER_FORMAT_PROPERTIES_ANDROID};
+ VkAndroidHardwareBufferPropertiesANDROID props = {
+ .sType = VK_STRUCTURE_TYPE_ANDROID_HARDWARE_BUFFER_PROPERTIES_ANDROID, .pNext = &fp};
+ VkResult pr = g_vk.GetAndroidHardwareBufferPropertiesANDROID(g_dev, ahb, &props);
+ if (pr != VK_SUCCESS || fp.format == VK_FORMAT_UNDEFINED) {
+ LOGE("ahb: %ux%u format %u: properties %s, Vulkan format %d", desc.width, desc.height, desc.format,
+ vk_result_name(pr), (int)fp.format);
+ return NULL;
+ }
+ struct vkp_image *img = calloc(1, sizeof(*img));
+ if (!img) return NULL;
+ img->w = (int)desc.width; img->h = (int)desc.height; img->dmabuf = 1; img->fmt = fp.format; img->sampled = 1;
+ VkExternalMemoryImageCreateInfo ext = {
+ .sType = VK_STRUCTURE_TYPE_EXTERNAL_MEMORY_IMAGE_CREATE_INFO,
+ .handleTypes = VK_EXTERNAL_MEMORY_HANDLE_TYPE_ANDROID_HARDWARE_BUFFER_BIT_ANDROID};
+ VkImageCreateInfo ici = {
+ .sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO, .pNext = &ext,
+ .imageType = VK_IMAGE_TYPE_2D, .format = fp.format, .extent = {desc.width, desc.height, 1},
+ .mipLevels = 1, .arrayLayers = 1, .samples = VK_SAMPLE_COUNT_1_BIT, .tiling = VK_IMAGE_TILING_OPTIMAL,
+ .usage = VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_SAMPLED_BIT,
+ .sharingMode = VK_SHARING_MODE_EXCLUSIVE, .initialLayout = VK_IMAGE_LAYOUT_UNDEFINED};
+ VkResult cr = g_vk.CreateImage(g_dev, &ici, NULL, &img->image);
+ if (cr != VK_SUCCESS) { LOGE("ahb: vkCreateImage %ux%u -> %s", desc.width, desc.height, vk_result_name(cr)); free(img); return NULL; }
+ int idx = -1;
+ for (int i = 0; i < 32; i++) if (props.memoryTypeBits & (1u << i)) { idx = i; break; }
+ VkImportAndroidHardwareBufferInfoANDROID imp = {
+ .sType = VK_STRUCTURE_TYPE_IMPORT_ANDROID_HARDWARE_BUFFER_INFO_ANDROID, .buffer = ahb};
+ VkMemoryDedicatedAllocateInfo ded = {.sType = VK_STRUCTURE_TYPE_MEMORY_DEDICATED_ALLOCATE_INFO,
+ .pNext = &imp, .image = img->image};
+ VkMemoryAllocateInfo mai = {.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO, .pNext = &ded,
+ .allocationSize = props.allocationSize, .memoryTypeIndex = (uint32_t)(idx < 0 ? 0 : idx)};
+ VkResult mr = idx < 0 ? VK_ERROR_FEATURE_NOT_PRESENT : g_vk.AllocateMemory(g_dev, &mai, NULL, &img->mem);
+ if (mr != VK_SUCCESS || g_vk.BindImageMemory(g_dev, img->image, img->mem, 0) != VK_SUCCESS) {
+ LOGE("ahb: importing %ux%u -> %s", desc.width, desc.height, vk_result_name(mr));
+ if (img->mem) g_vk.FreeMemory(g_dev, img->mem, NULL);
+ g_vk.DestroyImage(g_dev, img->image, NULL); free(img); return NULL;
+ }
+ return img;
+}
+
static int shm_sampled(void) {
static int known;
if (!known) {
diff --git a/app/src/main/cpp/waylandcomp/src/vk_present.h b/app/src/main/cpp/waylandcomp/src/vk_present.h
index 48d32ab4d..7271f468b 100644
--- a/app/src/main/cpp/waylandcomp/src/vk_present.h
+++ b/app/src/main/cpp/waylandcomp/src/vk_present.h
@@ -34,6 +34,9 @@ struct vkp_image *vkp_image_import_dmabuf(int fd, uint32_t drm_format, uint64_t
int vkp_image_is_dmabuf(const struct vkp_image *img);
struct vkp_image *vkp_image_create_shm(int w, int h);
+struct AHardwareBuffer;
+struct vkp_image *vkp_image_from_ahb(struct AHardwareBuffer *ahb);
+void vkp_wait_idle(void);
void vkp_image_upload_shm(struct vkp_image *img, const void *data, int stride);
int vkp_image_width(const struct vkp_image *img);
diff --git a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c
index bc349cdf6..16e42d1b5 100644
--- a/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c
+++ b/app/src/main/cpp/waylandcomp/src/waylandcomp_jni.c
@@ -9,6 +9,7 @@
#include
#include
#include
+#include
#include "vk_present.h"
#include "droiddeck_ext.h"
#include "ahb_swapchain.h"
@@ -27,6 +28,9 @@ extern int droiddeck_cursor_serial(void);
extern void droiddeck_wayland_send_key(int evdev, int state);
extern void droiddeck_wayland_send_scene_input(int type, int a, int b);
extern void droiddeck_wayland_vsync(int64_t frame_time_ns);
+extern void droiddeck_stream_set(int on);
+extern void droiddeck_stream_frame(AHardwareBuffer *ahb);
+extern unsigned droiddeck_stream_frames(void);
extern void droiddeck_wayland_output_resized(void);
extern void droiddeck_wayland_set_output_refresh_rate(int mhz);
extern volatile int g_fps_limit;
@@ -667,3 +671,21 @@ Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeSetCardboardSpatialP
(*env)->GetFloatArrayRegion(env, pos, 0, 3, p);
vkp_cardboard_set_spatial_pose(m, p, live ? 1 : 0);
}
+
+/* The Xbox Cloud Gaming native player: its decoded frames are the whole scene (no Wayland clients). */
+JNIEXPORT void JNICALL
+Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeStreamMode(JNIEnv *env, jclass clazz, jboolean on) {
+ droiddeck_stream_set(on ? 1 : 0);
+}
+
+JNIEXPORT void JNICALL
+Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeStreamFrame(JNIEnv *env, jclass clazz, jobject hardwareBuffer) {
+ if (!hardwareBuffer) return;
+ AHardwareBuffer *ahb = AHardwareBuffer_fromHardwareBuffer(env, hardwareBuffer);
+ if (ahb) droiddeck_stream_frame(ahb);
+}
+
+JNIEXPORT jint JNICALL
+Java_com_droiddeck_launcher_wayland_WaylandCompositor_nativeStreamFrames(JNIEnv *env, jclass clazz) {
+ return (jint)droiddeck_stream_frames();
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/App.kt b/app/src/main/java/com/droiddeck/launcher/App.kt
index aa26d5fca..928d7caa8 100644
--- a/app/src/main/java/com/droiddeck/launcher/App.kt
+++ b/app/src/main/java/com/droiddeck/launcher/App.kt
@@ -25,6 +25,15 @@ class App : Application() {
if (android.os.Build.VERSION.SDK_INT >= 28) android.webkit.WebView.setDataDirectorySuffix("xbox")
return
}
+ if (process == "$packageName:xboxcloud") {
+ if (android.os.Build.VERSION.SDK_INT >= 28) android.webkit.WebView.setDataDirectorySuffix("xboxcloud")
+ return
+ }
+ if (process == "$packageName:xcloudnative") {
+ // The native cloud player: the stores log and nothing that touches the Linux session.
+ com.droiddeck.launcher.stores.StoresState.init(this)
+ return
+ }
if (process == "$packageName:vr_recovery" || process == "$packageName:rgb_diagnostic") return
CrashHandler.install(this)
com.droiddeck.launcher.session.QuestExperimental.initialize(this)
diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt
index 59cf85061..05f3f9869 100644
--- a/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt
+++ b/app/src/main/java/com/droiddeck/launcher/frontend/Library.kt
@@ -27,6 +27,7 @@ object Library {
const val ADDED = "added"
const val EPIC_LAUNCHER = "epic-launcher"
const val EPIC_LAUNCHER_NAME = "Epic Games Launcher"
+ const val XBOX_CLOUD = "xbox-cloud"
const val SOURCE_STEAM = "steam"
class Rom(val name: String, val hostPath: File, val guestPath: String, val emulatorId: String, val art: File? = null)
diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/WinCompSources.kt b/app/src/main/java/com/droiddeck/launcher/frontend/WinCompSources.kt
index 7562cc1ee..63e862779 100644
--- a/app/src/main/java/com/droiddeck/launcher/frontend/WinCompSources.kt
+++ b/app/src/main/java/com/droiddeck/launcher/frontend/WinCompSources.kt
@@ -3,12 +3,13 @@ package com.droiddeck.launcher.frontend
import android.content.Context
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreGameSidecar
+import com.droiddeck.launcher.session.WinComponents
import org.json.JSONObject
import java.io.File
import java.util.Locale
object WinCompSources {
- enum class Origin { STEAM, GOG, EPIC, AMAZON, STEAM_NAME, FOLDER }
+ enum class Origin { STEAM, GOG, EPIC, AMAZON, STEAM_NAME, FOLDER, XBOX }
data class Finding(val component: String?, val original: String, val origin: Origin, val auto: Boolean, val args: List = emptyList())
@@ -49,6 +50,17 @@ object WinCompSources {
fun installer(path: String): List = if (UE_PREREQ.containsMatchIn(path)) listOf("vcredist2022", "d3dx9") else DependencyDetector.componentsFor(path)
+ private val XBOX_KNOWN = Regex("""> {
+ val config = folder?.listFiles().orEmpty().firstOrNull { it.isFile && it.name.equals("MicrosoftGame.config", ignoreCase = true) && it.length() < 1L shl 20 }
+ val declared = config?.let { file -> runCatching { XBOX_KNOWN.findAll(file.readText()).map { it.groupValues[1] }.toList() }.getOrNull() }.orEmpty()
+ return (listOf("VC14") + declared).flatMap { name -> XBOX_COMPONENTS[name].orEmpty().map { it to name } }.distinctBy { it.first }
+ }
+
fun steamDepots(ids: List): List> = ids.mapNotNull { SteamRedists.DEPOTS[it] }.distinctBy { it.first }
@@ -94,6 +106,7 @@ object WinCompSources {
parts.forEach { out += Finding(it, original, Origin.EPIC, true) }
}
}
+ store == Store.XBOX -> xbox(folder).forEach { (component, original) -> out += Finding(component, original, Origin.XBOX, true) }
store == Store.AMAZON -> folder?.let { amazonPostInstall(it) }?.forEach { command ->
val parts = installer(command)
val original = command.replace('\\', '/').substringAfterLast('/')
diff --git a/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt b/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt
index 4282ecf6f..3d493c606 100644
--- a/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt
+++ b/app/src/main/java/com/droiddeck/launcher/gpu/FrameGen.kt
@@ -50,8 +50,10 @@ object FrameGen {
else -> context.getString(R.string.frame_gen_off)
}
- fun apply(context: Context, refreshHz: Float): String? {
- val mode = mode(context)
+ fun apply(context: Context, refreshHz: Float): String? = apply(context, mode(context), refreshHz)
+
+ /** Arms [mode] in this process's compositor; the cloud player passes its own mode and frame-rate cap. */
+ fun apply(context: Context, mode: Mode, refreshHz: Float): String? {
when (mode.engine) {
ENGINE_LSFG -> {
val status = Lossless.sync(context)
diff --git a/app/src/main/java/com/droiddeck/launcher/session/WinComponents.kt b/app/src/main/java/com/droiddeck/launcher/session/WinComponents.kt
index 2c5a1aa0d..4b355caa4 100644
--- a/app/src/main/java/com/droiddeck/launcher/session/WinComponents.kt
+++ b/app/src/main/java/com/droiddeck/launcher/session/WinComponents.kt
@@ -101,10 +101,28 @@ object WinComponents {
fun cached(context: Context): List? =
runCatching { parse(File(context.filesDir, CATALOG_CACHE).readText()) }.getOrNull()
+ const val VCREDIST_ARM64 = "vcredist2022_arm64"
+
+ /**
+ * The ARM64 Visual C++ 2015-2022 runtime as registry values only (the version a 14.44.35211 installer writes, in both
+ * registry views). Xbox games' launchers running as ARM64EC check for it; Wine's own CRT does the work. Same approach as
+ * MGamer404's Bannerlator #627. Used until the catalog carries a component of this name.
+ */
+ internal fun builtInArm64Runtime(): Component {
+ fun value(view: String, name: String, type: String, data: String) = Step("set_register_key", JSONObject()
+ .put("action", "set_register_key").put("key", "HKLM\\Software\\${view}Microsoft\\VisualStudio\\14.0\\VC\\Runtimes\\arm64")
+ .put("value", name).put("type", type).put("data", data))
+ val steps = listOf("", "Wow6432Node\\").flatMap { view ->
+ listOf(value(view, "Installed", "REG_DWORD", "1"), value(view, "Major", "REG_DWORD", "14"), value(view, "Minor", "REG_DWORD", "44"),
+ value(view, "Bld", "REG_DWORD", "35211"), value(view, "Rbld", "REG_DWORD", "0"), value(view, "Version", "REG_SZ", "v14.44.35211.00"))
+ }
+ return Component(VCREDIST_ARM64, "Microsoft Visual C++ 2015-2022 ARM64 runtime 14.44.35211 (registry entries only)", "Microsoft", "ready", emptyList(), steps)
+ }
+
private fun parse(body: String): List? {
return try {
val arr = JSONObject(body).getJSONArray("components")
- (0 until arr.length()).mapNotNull { i ->
+ val listed = (0 until arr.length()).mapNotNull { i ->
val o = arr.getJSONObject(i)
val name = o.optString("name")
if (!validId(name)) return@mapNotNull null
@@ -117,6 +135,7 @@ object WinComponents {
o.optString("snapshot"),
)
}
+ if (listed.any { it.name == VCREDIST_ARM64 }) listed else listed + builtInArm64Runtime()
} catch (e: Exception) {
Log.w(TAG, "catalog: $e"); null
}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt
index 482619e6f..cf7fc2dfa 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/StoresState.kt
@@ -184,6 +184,8 @@ object StoresState {
}
var openDownloads by mutableStateOf(false)
+ /** A "store:id" key another page asks the Stores page to open. */
+ var openGameRequest by mutableStateOf(null)
private fun askForNotifications(context: Context) {
val activity = context as? android.app.Activity ?: return
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt
index 9121a35c9..a50b56612 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxBackend.kt
@@ -26,6 +26,8 @@ object XboxBackend : StoreBackend {
access = emptyMap()
}
XboxLibraryCache.clear(context)
+ XboxCloudSession.clear()
+ XboxCloudActivity.clearWebSignIn(context)
XboxRuntime.stop()
}
StoresState.post { packageChoice = null; StoresState.shelves.remove(store); StoresState.found.remove(store) }
@@ -57,9 +59,13 @@ object XboxBackend : StoreBackend {
val response = Xodus.run(app, listOf("library", "--json", "--market", market))
val licenses = XboxCatalog.access(response, account, market, report = { StoresState.logLine("Xbox: $it") })
val products = XboxCatalog.rawProducts(licenses.keys.toList(), market)
- val games = products.mapNotNull { XboxCatalog.product(it, licenses) }.sortedBy { it.title.lowercase() }
+ val cloud = runCatching { XboxCatalog.cloudCatalog(market) }
+ .onFailure { StoresState.logLine("Xbox: cloud lists unavailable (${it.javaClass.simpleName})") }.getOrNull()
+ val games = mergeEditions(products.mapNotNull { XboxCatalog.product(it, licenses, cloud) }).sortedBy { it.title.lowercase() }
StoresState.logLine("Xbox: library catalog requested=${licenses.size} returned=${products.size} supported=${games.size} " +
- "filtered=${products.size - games.size} backend-skipped=${response.optInt("skippedEntitlements")}")
+ "filtered=${products.size - games.size} backend-skipped=${response.optInt("skippedEntitlements")} " +
+ "pc=${games.count { it.xboxPc }} cloud=${games.count { it.xboxCloud }} cloud-only=${games.count { !it.xboxPc }} " +
+ "cloud-lists=${cloud?.gamePass?.size ?: -1}/${cloud?.all?.size ?: -1}")
synchronized(this) {
if (current != generation || StoreAccounts.signedInAs(app, store) != account) return@Thread
access = licenses
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt
index 100371b06..d4a16c409 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCatalog.kt
@@ -10,7 +10,7 @@ import java.time.Instant
internal object XboxCatalog {
private val productId = Regex("[A-Z0-9]{12}")
private val plans = setOf("CFQ7TTC0KGQ8", "CFQ7TTC0KHS0", "CFQ7TTC0P85B", "CFQ7TTC0K5DJ", "CFQ7TTC0K6L8", "CFQ7TTC10QFD")
- /** Plans that include PC games; the retired console-only plan must not offer a Windows install. */
+ /** Plans whose store offers advertise PC games. Library entitlements under any active plan count, as the backend resolves a PC package for them. */
private val pcPlans = mapOf(
"CFQ7TTC0KGQ8" to "PC Game Pass",
"CFQ7TTC0KHS0" to "Game Pass Ultimate",
@@ -61,7 +61,7 @@ internal object XboxCatalog {
val parent = row.optString("satisfiedByProductId").takeUnless { it.isBlank() || it == "null" }
if (parent == null) return if (id in plans) null else "owned"
if (parent in blocked) return null
- if (parent in plans) return if (parent in pcPlans && activePlan(parent)) "gamepass" else null
+ if (parent in plans) return if (activePlan(parent)) "gamepass" else null
return kind(parent, visited + id)
}
val result = rows.keys.mapNotNull { id -> kind(id)?.let { id to it } }.toMap()
@@ -98,7 +98,7 @@ internal object XboxCatalog {
return products(objects(body.optJSONArray("productsList")).map { it.optString("productId") }.take(24), access)
}
- internal fun product(json: JSONObject, access: Map): CatalogItem? {
+ internal fun product(json: JSONObject, access: Map, cloud: XboxCloudCatalog? = null): CatalogItem? {
val id = json.optString("ProductId").uppercase()
if (!productId.matches(id) || json.optString("ProductType") != "Game") return null
val localized = objects(json.optJSONArray("LocalizedProperties")).firstOrNull() ?: return null
@@ -107,7 +107,9 @@ internal object XboxCatalog {
objects(it.optJSONObject("Sku")?.optJSONObject("Properties")?.optJSONArray("Packages"))
}
val pc = packages.filter { pkg -> objects(pkg.optJSONArray("PlatformDependencies")).any { it.optString("PlatformName") == "Windows.Desktop" } }
- if (pc.none { it.optString("PackageFormat").equals("MSIXVC", true) }) return null
+ val installable = pc.any { it.optString("PackageFormat").equals("MSIXVC", true) }
+ val streams = cloud?.streams(id, access[id].orEmpty()) == true
+ if (!installable && !streams) return null
val images = objects(localized.optJSONArray("Images"))
fun image(vararg kinds: String): String? = kinds.firstNotNullOfOrNull { purpose ->
images.firstOrNull { it.optString("ImagePurpose") == purpose }?.optString("Uri")?.let { raw ->
@@ -127,13 +129,57 @@ internal object XboxCatalog {
storeUrl = "https://www.xbox.com/games/store/$id",
publisher = localized.optString("PublisherName"), developer = localized.optString("DeveloperName"),
description = localized.optString("ProductDescription"),
- sizeBytes = pc.maxOfOrNull { it.optLong("MaxDownloadSizeInBytes") } ?: 0L,
- extra = mapOf("market" to Xodus.market(), "access" to kind.orEmpty()),
+ sizeBytes = if (installable) pc.maxOfOrNull { it.optLong("MaxDownloadSizeInBytes") } ?: 0L else 0L,
+ extra = mapOf("market" to Xodus.market(), "access" to kind.orEmpty(), "pc" to flag(installable), "cloud" to flag(streams)),
)
}
+ private const val CLOUD_GAME_PASS = "29a81209-df6f-41fd-a528-2ae6b91f719c"
+ private const val CLOUD_ALL = "ce573635-7c18-4d0c-9d68-90b932393470"
+
+ /** Microsoft's public cloud lists (catalog.gamepass.com "sigls"; ids as Better xCloud by redphx, MIT, uses them). */
+ fun cloudCatalog(market: String = Xodus.market()): XboxCloudCatalog =
+ XboxCloudCatalog(cloudList(CLOUD_GAME_PASS, market), cloudList(CLOUD_ALL, market))
+
+ private fun cloudList(id: String, market: String): Set {
+ val body = JSONArray(StoreNet.get("https://catalog.gamepass.com/sigls/v2?id=$id&language=en-us&market=$market") ?: error("Xbox cloud list unavailable"))
+ return (0 until body.length()).mapNotNull { body.optJSONObject(it)?.optString("id")?.uppercase()?.takeIf(productId::matches) }.toSet()
+ }
+
+ fun cloudUrl(id: String, title: String, cloudId: String = id): String =
+ "https://www.xbox.com/play/launch/${title.lowercase().replace(Regex("[^a-z0-9]+"), "-").trim('-').ifEmpty { "game" }}/$cloudId"
+
+ internal fun flag(value: Boolean) = if (value) "1" else "0"
+
fun objects(array: JSONArray?): List = if (array == null) emptyList() else
(0 until array.length()).mapNotNull { array.optJSONObject(it) }
private fun get(url: String): JSONObject = JSONObject(StoreNet.get(url) ?: error("Microsoft Store could not be reached. Try again."))
}
+
+internal data class XboxCloudCatalog(val gamePass: Set, val all: Set) {
+ fun streams(id: String, access: String): Boolean = when (access) {
+ "gamepass" -> id in gamePass
+ "owned" -> id in all || id in gamePass
+ else -> false
+ }
+}
+
+val CatalogItem.xboxPc: Boolean get() = extra["pc"] != "0"
+val CatalogItem.xboxCloud: Boolean get() = extra["cloud"] == "1"
+val CatalogItem.xboxCloudId: String get() = extra["cloudId"]?.takeIf { it.isNotEmpty() } ?: id
+
+/** Microsoft lists console and PC editions as separate products; a cloud-only one with a PC game's title folds into that card. */
+internal fun mergeEditions(games: List): List {
+ fun key(title: String) = title.lowercase().replace(Regex("[^a-z0-9]+"), "")
+ val cloudOnly = games.filter { !it.xboxPc && it.xboxCloud }.groupBy { key(it.title) }
+ val folded = HashSet()
+ val merged = games.map { game ->
+ if (!game.xboxPc || game.xboxCloud) game
+ else cloudOnly[key(game.title)]?.firstOrNull()?.let { console ->
+ folded.add(console.id)
+ game.copy(extra = game.extra + mapOf("cloud" to "1", "cloudId" to console.id))
+ } ?: game
+ }
+ return merged.filter { it.id !in folded }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudActivity.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudActivity.kt
new file mode 100644
index 000000000..1fa7bdfe5
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudActivity.kt
@@ -0,0 +1,189 @@
+package com.droiddeck.launcher.stores.xbox
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.Intent
+import android.graphics.Color
+import android.os.Bundle
+import android.util.Log
+import android.view.InputDevice
+import android.view.KeyEvent
+import android.view.View
+import android.view.WindowManager
+import android.webkit.ConsoleMessage
+import android.webkit.CookieManager
+import android.webkit.PermissionRequest
+import android.webkit.WebChromeClient
+import android.webkit.WebResourceRequest
+import android.webkit.WebSettings
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import android.widget.FrameLayout
+import androidx.activity.ComponentActivity
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+import androidx.webkit.WebSettingsCompat
+import androidx.webkit.WebViewCompat
+import androidx.webkit.WebViewFeature
+
+/**
+ * Microsoft's web player (xbox.com/play) in its own process and WebView data directory: the store sign-in
+ * clears its own web data after each attempt, so the player keeps a separate Microsoft session. Signing out of
+ * the store clears it ([clearWebSignIn]).
+ */
+class XboxCloudActivity : ComponentActivity() {
+ private var web: WebView? = null
+ private var custom: View? = null
+ private var customCallback: WebChromeClient.CustomViewCallback? = null
+ private lateinit var root: FrameLayout
+
+ @SuppressLint("SetJavaScriptEnabled")
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ hideBars()
+ root = FrameLayout(this).apply { setBackgroundColor(Color.BLACK) }
+ setContentView(root)
+ Log.i(TAG, "WebView ${WebViewCompat.getCurrentWebViewPackage(this)?.versionName}")
+
+ val view = WebView(this)
+ web = view
+ view.settings.apply {
+ javaScriptEnabled = true
+ domStorageEnabled = true
+ databaseEnabled = true
+ mediaPlaybackRequiresUserGesture = false
+ allowFileAccess = false
+ allowContentAccess = false
+ mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
+ useWideViewPort = true
+ loadWithOverviewMode = true
+ userAgentString = chromeUserAgent(userAgentString)
+ }
+ // No "X-Requested-With: " header: it marks the page as embedded in an app.
+ if (WebViewFeature.isFeatureSupported(WebViewFeature.REQUESTED_WITH_HEADER_ALLOW_LIST))
+ WebSettingsCompat.setRequestedWithHeaderOriginAllowList(view.settings, emptySet())
+ Log.i(TAG, "UA ${view.settings.userAgentString}")
+ CookieManager.getInstance().setAcceptCookie(true)
+ CookieManager.getInstance().setAcceptThirdPartyCookies(view, true)
+ view.webViewClient = object : WebViewClient() {
+ override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean =
+ request.url.scheme != "https"
+ override fun onPageFinished(view: WebView, url: String) { Log.i(TAG, "page ${url.substringBefore('?')}") }
+ }
+ view.webChromeClient = object : WebChromeClient() {
+ override fun onConsoleMessage(message: ConsoleMessage): Boolean {
+ Log.d(TAG, "console ${message.messageLevel()}: ${message.message().take(300)}")
+ return true
+ }
+ override fun onPermissionRequest(request: PermissionRequest) {
+ Log.i(TAG, "permission request ${request.resources.joinToString()} denied")
+ request.deny()
+ }
+ override fun onShowCustomView(view: View, callback: CustomViewCallback) {
+ custom?.let { root.removeView(it) }
+ custom = view; customCallback = callback
+ root.addView(view, FrameLayout.LayoutParams(-1, -1))
+ hideBars()
+ }
+ override fun onHideCustomView() {
+ custom?.let { root.removeView(it) }
+ custom = null; customCallback = null
+ }
+ }
+ root.addView(view, FrameLayout.LayoutParams(-1, -1))
+ if (savedInstanceState != null) view.restoreState(savedInstanceState)
+ else view.loadUrl(startUrl(intent))
+ }
+
+ override fun onNewIntent(intent: Intent) {
+ super.onNewIntent(intent)
+ setIntent(intent)
+ web?.loadUrl(startUrl(intent))
+ }
+
+ private fun startUrl(intent: Intent): String =
+ intent.getStringExtra("url")?.takeIf { url -> runCatching { java.net.URI(url) }.getOrNull()?.let {
+ it.scheme == "https" && it.host == "www.xbox.com" && it.userInfo == null && it.port == -1 } == true } ?: START_URL
+
+ // Without the WebView markers the page treats this as Chrome for Android.
+ private fun chromeUserAgent(default: String): String =
+ default.replace("; wv)", ")").replace(Regex(" Version/\\d+(\\.\\d+)*"), "")
+
+ private fun hideBars() {
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+ WindowInsetsControllerCompat(window, window.decorView).apply {
+ hide(WindowInsetsCompat.Type.systemBars())
+ systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+ }
+ }
+
+ override fun onWindowFocusChanged(hasFocus: Boolean) {
+ super.onWindowFocusChanged(hasFocus)
+ if (hasFocus) hideBars()
+ }
+
+ // A controller's unused B arrives as BACK; ask before leaving so a stray press never ends a stream.
+ override fun dispatchKeyEvent(event: KeyEvent): Boolean {
+ if (event.keyCode == KeyEvent.KEYCODE_BACK && event.action == KeyEvent.ACTION_UP) {
+ val fromController = event.source and InputDevice.SOURCE_GAMEPAD == InputDevice.SOURCE_GAMEPAD ||
+ event.source and InputDevice.SOURCE_JOYSTICK == InputDevice.SOURCE_JOYSTICK
+ when {
+ custom != null -> customCallback?.onCustomViewHidden()
+ fromController -> confirmLeave()
+ web?.canGoBack() == true -> web?.goBack()
+ else -> finish()
+ }
+ return true
+ }
+ if (event.keyCode == KeyEvent.KEYCODE_BACK) return true
+ return super.dispatchKeyEvent(event)
+ }
+
+ private var leaving: android.app.AlertDialog? = null
+
+ private fun confirmLeave() {
+ if (leaving?.isShowing == true) return
+ leaving = android.app.AlertDialog.Builder(this, android.R.style.Theme_DeviceDefault_Dialog_Alert)
+ .setTitle("Leave Xbox Cloud Gaming?")
+ .setMessage("The stream ends when you leave.")
+ .setPositiveButton("Leave") { _, _ -> finish() }
+ .setNegativeButton("Stay", null)
+ .show()
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+ web?.saveState(outState)
+ }
+
+ override fun onPause() { super.onPause(); CookieManager.getInstance().flush() }
+
+ override fun onDestroy() {
+ web?.let { root.removeView(it); it.stopLoading(); it.destroy() }
+ web = null
+ super.onDestroy()
+ }
+
+ companion object {
+ private const val TAG = "XboxCloud"
+ const val START_URL = "https://www.xbox.com/play"
+
+ fun intent(context: Context, url: String? = null): Intent = Intent(context, XboxCloudActivity::class.java)
+ .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ .apply { if (url != null) putExtra("url", url) }
+
+ const val PROCESS_SUFFIX = ":xboxcloud"
+ const val WEBVIEW_SUFFIX = "xboxcloud"
+
+ fun clearWebSignIn(context: Context) {
+ val am = context.getSystemService(Context.ACTIVITY_SERVICE) as android.app.ActivityManager
+ am.runningAppProcesses.orEmpty().filter { it.processName.endsWith(PROCESS_SUFFIX) }
+ .forEach { android.os.Process.killProcess(it.pid) }
+ val suffix = WEBVIEW_SUFFIX
+ listOf(java.io.File(context.applicationInfo.dataDir, "app_webview_$suffix"), java.io.File(context.cacheDir.parentFile, "cache/webview_$suffix"))
+ .forEach { runCatching { it.deleteRecursively() } }
+ }
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudGames.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudGames.kt
new file mode 100644
index 000000000..10b034eaa
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudGames.kt
@@ -0,0 +1,135 @@
+package com.droiddeck.launcher.stores.xbox
+
+import android.content.Context
+import android.content.Intent
+import androidx.compose.runtime.mutableStateListOf
+import com.droiddeck.launcher.frontend.Library
+import com.droiddeck.launcher.session.SessionState
+import com.droiddeck.launcher.stores.CatalogItem
+import com.droiddeck.launcher.stores.Store
+import com.droiddeck.launcher.stores.StoresState
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.File
+import java.net.HttpURLConnection
+import java.net.URL
+
+/** Xbox games added to the Games tab to stream before (or without) installing them. */
+object XboxCloudGames {
+ class Entry(val id: String, val title: String, val poster: String?, val hero: String?)
+
+ private const val PREFS = "xbox_cloud_games"
+ private val idPattern = Regex("[A-Z0-9]{12}")
+ val entries = mutableStateListOf()
+ @Volatile private var loaded = false
+
+ fun load(context: Context) {
+ if (loaded) return
+ loaded = true
+ val list = decode(context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString("games", null))
+ StoresState.post { entries.clear(); entries.addAll(list) }
+ }
+
+ fun isAdded(id: String) = entries.any { it.id == id }
+
+ fun add(context: Context, item: CatalogItem) {
+ val app = context.applicationContext
+ if (isAdded(item.id)) return
+ entries.add(Entry(item.id, item.title, item.tallImageUrl, item.imageUrl))
+ save(app)
+ Thread({
+ fetch(item.tallImageUrl, File(artDir(app, item.id), "poster"))
+ fetch(item.imageUrl, File(artDir(app, item.id), "hero"))
+ StoresState.post { StoresState.notifyLibraryChanged(app) }
+ }, "xbox-cloud-art").start()
+ }
+
+ fun remove(context: Context, id: String) {
+ val app = context.applicationContext
+ entries.removeAll { it.id == id }
+ save(app)
+ artDir(app, id).deleteRecursively()
+ }
+
+ private fun save(app: Context) {
+ app.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString("games", encode(entries.toList())).apply()
+ }
+
+ internal fun encode(list: List): String = JSONArray().apply {
+ list.forEach { put(JSONObject().put("id", it.id).put("title", it.title).put("poster", it.poster.orEmpty()).put("hero", it.hero.orEmpty())) }
+ }.toString()
+
+ internal fun decode(text: String?): List = runCatching {
+ val array = JSONArray(text ?: return emptyList())
+ (0 until minOf(array.length(), 2000)).mapNotNull { array.optJSONObject(it) }.mapNotNull { o ->
+ val id = o.optString("id")
+ val title = o.optString("title")
+ if (!idPattern.matches(id) || title.isBlank()) null
+ else Entry(id, title.take(200), o.optString("poster").takeIf { it.isNotEmpty() }, o.optString("hero").takeIf { it.isNotEmpty() })
+ }.distinctBy { it.id }
+ }.getOrDefault(emptyList())
+
+ /** Games-tab rows for the added games that are not installed (an installed copy has its own row). */
+ fun rows(context: Context, installedIds: Set): List = entries.filter { it.id !in installedIds }.map { e ->
+ val dir = artDir(context, e.id)
+ val appId = rowId(e.id)
+ Library.SteamGame(appId = appId, name = e.title, art = File(dir, "poster").takeIf { it.isFile }, library = Library.XBOX_CLOUD,
+ hero = File(dir, "hero").takeIf { it.isFile }, source = Store.XBOX.id, storeId = e.id)
+ }
+
+ internal fun rowId(id: String): Int = ("xbox-cloud:$id").hashCode() or Int.MIN_VALUE
+
+ private fun artDir(context: Context, id: String) = File(context.filesDir, "xbox-cloud-art/$id")
+
+ private fun fetch(url: String?, target: File) {
+ if (url == null) return
+ runCatching {
+ val parsed = URL(url)
+ require(parsed.protocol == "https" && parsed.host in setOf("store-images.s-microsoft.com", "store-images.microsoft.com"))
+ val connection = parsed.openConnection() as HttpURLConnection
+ try {
+ connection.connectTimeout = 15_000; connection.readTimeout = 30_000
+ check(connection.responseCode == 200)
+ val bytes = connection.inputStream.use { it.readBytes() }
+ require(bytes.size in 1..12_000_000)
+ target.parentFile?.mkdirs()
+ File(target.path + ".tmp").apply { writeBytes(bytes); check(renameTo(target)) }
+ } finally { connection.disconnect() }
+ }
+ }
+
+ private val prefetching = java.util.concurrent.atomic.AtomicBoolean(false)
+
+ /**
+ * Fetches the streaming tokens in the background while a cloud game is on screen (about 20 s), so Stream starts at
+ * once; both players take them from this process (the native one through XboxCloudTokenService).
+ */
+ fun prefetch(context: Context) {
+ val app = context.applicationContext
+ if (!prefetching.compareAndSet(false, true)) return
+ Thread({
+ try {
+ val account = com.droiddeck.launcher.stores.StoreAccounts.signedInAs(app, Store.XBOX) ?: return@Thread
+ runCatching { XboxCloudSession.get(app, account) }
+ .onFailure { StoresState.logLine("Xbox: cloud tokens not fetched ahead: ${it.message?.take(160)}") }
+ } finally { prefetching.set(false) }
+ }, "xcloud-prefetch").start()
+ }
+
+ /** Streaming runs outside the Linux session; the session owns the screen, audio and controllers while it runs. */
+ fun stream(context: Context, id: String, title: String) {
+ if (SessionState.running) {
+ android.widget.Toast.makeText(context, "Close the Steam session to stream Xbox games.", android.widget.Toast.LENGTH_LONG).show()
+ return
+ }
+ context.startActivity(XboxCloudPlayerActivity.playerIntent(context, id, title).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
+ }
+
+ /** Whether the signed-in library says this game streams (cached library; reads disk). */
+ fun streams(context: Context, id: String): Boolean = runCatching {
+ StoresState.library[Store.XBOX]?.firstOrNull { it.id == id }?.let { return it.xboxCloud }
+ val app = context.applicationContext
+ val account = com.droiddeck.launcher.stores.StoreAccounts.signedInAs(app, Store.XBOX) ?: return false
+ XboxLibraryCache.read(app, account, Xodus.market())?.games?.firstOrNull { it.id == id }?.xboxCloud == true
+ }.getOrDefault(false)
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudPlayerActivity.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudPlayerActivity.kt
new file mode 100644
index 000000000..9b8d11c0d
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudPlayerActivity.kt
@@ -0,0 +1,399 @@
+package com.droiddeck.launcher.stores.xbox
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.content.Intent
+import android.graphics.Color
+import android.os.Bundle
+import android.view.Gravity
+import android.view.KeyEvent
+import android.view.WindowManager
+import android.webkit.JavascriptInterface
+import android.webkit.WebResourceRequest
+import android.webkit.WebResourceResponse
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import android.widget.Button
+import android.widget.FrameLayout
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.activity.ComponentActivity
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+import androidx.webkit.WebViewAssetLoader
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.unit.dp
+import com.droiddeck.launcher.stores.Store
+import com.droiddeck.launcher.stores.StoreAccounts
+import com.droiddeck.launcher.stores.StoresState
+import org.json.JSONObject
+import java.util.concurrent.Executors
+
+/** Runs the WebRTC client page (tools/xcloud-player); signalling and tokens stay on this side of the bridge. */
+class XboxCloudPlayerActivity : ComponentActivity() {
+ private lateinit var root: FrameLayout
+ private var web: WebView? = null
+ private var notice: LinearLayout? = null
+ private val worker = Executors.newSingleThreadExecutor()
+ private val signalling = Executors.newCachedThreadPool()
+ @Volatile private var session: XboxCloudSession.Session? = null
+ @Volatile private var sessionPath: String? = null
+ @Volatile private var titleId: String? = null
+ @Volatile private var closed = false
+ @Volatile private var webUrl: String? = null
+ @Volatile private var settings = XboxCloudSettings()
+ @Volatile private var sessionSignals: XboxCloudSignalling? = null
+ /** The settings this stream started with (stream-start settings changed since then need a restart). */
+ @Volatile private var startedWith: XboxCloudSettings? = null
+ private var menu: androidx.compose.ui.platform.ComposeView? = null
+
+ @SuppressLint("SetJavaScriptEnabled")
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ hideBars()
+ watchBars()
+ root = FrameLayout(this).apply { setBackgroundColor(Color.BLACK) }
+ setContentView(root)
+ val requested = intent.getStringExtra("product").orEmpty()
+ showNotice("Signing in to Xbox Cloud Gamingโฆ", withButtons = false)
+ worker.execute {
+ val result = runCatching {
+ val account = StoreAccounts.signedInAs(applicationContext, Store.XBOX) ?: error("Sign in to the Xbox store first.")
+ val cached = XboxLibraryCache.read(applicationContext, account, Xodus.market())?.games?.firstOrNull { it.id == requested }
+ val product = cached?.extra?.get("cloudId")?.takeIf { it.isNotEmpty() } ?: requested
+ webUrl = XboxCatalog.cloudUrl(requested, cached?.title ?: intent.getStringExtra("title").orEmpty(), product)
+ val active = XboxCloudSession.get(applicationContext, account)
+ val title = XboxCloudSession.titles(active)[product] ?: error("This game can't be streamed with this account here.")
+ settings = XboxCloudSettings.load(applicationContext)
+ startedWith = settings
+ session = active.forStream(settings.region, settings.osName); titleId = title
+ diag("cloud player: $product -> title $title, region ${java.net.URI(session!!.baseUri).host}, transfer=${active.userToken != null}")
+ }
+ runOnUiThread {
+ if (closed) return@runOnUiThread
+ result.onSuccess { openPlayer() }.onFailure {
+ diag("cloud player not started: ${it.javaClass.simpleName}: ${it.message?.take(160)}")
+ showNotice(it.message ?: "Xbox Cloud Gaming could not start.", withButtons = true)
+ }
+ }
+ }
+ }
+
+ @SuppressLint("SetJavaScriptEnabled")
+ private fun openPlayer() {
+ val loader = WebViewAssetLoader.Builder().addPathHandler("/assets/", WebViewAssetLoader.AssetsPathHandler(this)).build()
+ val view = WebView(this)
+ web = view
+ view.settings.apply {
+ javaScriptEnabled = true
+ mediaPlaybackRequiresUserGesture = false
+ allowFileAccess = false
+ allowContentAccess = false
+ }
+ view.setBackgroundColor(Color.BLACK)
+ // The Gamepad API only sees controllers while the WebView has input focus.
+ view.isFocusable = true
+ view.isFocusableInTouchMode = true
+ view.addJavascriptInterface(Bridge(), "DDCloud")
+ view.webViewClient = object : WebViewClient() {
+ override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? =
+ loader.shouldInterceptRequest(request.url)
+ override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean = true
+ }
+ view.webChromeClient = object : android.webkit.WebChromeClient() {
+ override fun onConsoleMessage(message: android.webkit.ConsoleMessage): Boolean {
+ android.util.Log.d("XboxCloudPlayer", "console ${message.messageLevel()}: ${message.message().take(300)}")
+ return true
+ }
+ }
+ root.addView(view, 0, FrameLayout.LayoutParams(-1, -1))
+ notice?.let { root.removeView(it) }
+ notice = null
+ view.requestFocus()
+ view.loadUrl("https://appassets.androidplatform.net/assets/xcloud/player.html")
+ }
+
+ private inner class Bridge {
+ @JavascriptInterface
+ fun call(id: Int, op: String, arg: String) {
+ signalling.execute {
+ val reply = runCatching { handle(op, arg) }.getOrElse { XboxCloudSession.Reply(0, it.javaClass.simpleName) }
+ if (reply.status !in 200..299) diag("cloud player $op: ${reply.status} ${reply.body.take(300)}")
+ else if (op != "state" && op != "keepalive") diag("cloud player $op: ${reply.status}")
+ runOnUiThread { web?.evaluateJavascript("window.__ddResolve($id, ${reply.status}, ${JSONObject.quote(reply.body)})", null) }
+ }
+ }
+
+ @JavascriptInterface
+ fun status(text: String) { android.util.Log.i("XboxCloudPlayer", "status: ${text.take(120)}") }
+
+ @JavascriptInterface
+ fun playing() {
+ diag("cloud player streaming")
+ runOnUiThread { web?.requestFocus() }
+ }
+
+ @JavascriptInterface
+ fun settings(): String = pageSettings(this@XboxCloudPlayerActivity.settings)
+
+ @JavascriptInterface
+ fun log(text: String) { diag("cloud player page: ${text.take(240)}") }
+
+ @JavascriptInterface
+ fun failed(stage: String, detail: String) {
+ diag("cloud player failed at ${stage.take(40)}: ${detail.take(300)}")
+ runOnUiThread { if (!closed) showNotice("The stream could not start ($stage).", withButtons = true) }
+ }
+ }
+
+ private fun handle(op: String, arg: String): XboxCloudSession.Reply {
+ val active = session ?: error("no session")
+ val signals = sessionSignals ?: XboxCloudSignalling(active, titleId ?: error("no title"), settings).also { sessionSignals = it }
+ return signals.op(op, arg).also { sessionPath = signals.path }
+ }
+
+ private fun showNotice(text: String, withButtons: Boolean) {
+ notice?.let { root.removeView(it) }
+ val box = LinearLayout(this).apply {
+ orientation = LinearLayout.VERTICAL
+ gravity = Gravity.CENTER
+ setBackgroundColor(if (withButtons) Color.argb(230, 0, 0, 0) else Color.TRANSPARENT)
+ }
+ box.addView(TextView(this).apply { setTextColor(Color.WHITE); textSize = 18f; gravity = Gravity.CENTER; this.text = text; setPadding(32, 16, 32, 16) })
+ if (withButtons) {
+ val row = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL; gravity = Gravity.CENTER }
+ (webUrl ?: intent.getStringExtra("webUrl"))?.let { url ->
+ row.addView(Button(this).apply { this.text = "Open in Microsoft's player"; setOnClickListener { startActivity(XboxCloudActivity.intent(this@XboxCloudPlayerActivity, url)); finish() } })
+ }
+ row.addView(Button(this).apply { this.text = "Close"; setOnClickListener { finish() } })
+ box.addView(row)
+ }
+ notice = box
+ root.addView(box, FrameLayout.LayoutParams(-1, -1))
+ }
+
+ private val rehideBars = Runnable { if (window.decorView.hasWindowFocus()) hideBars() }
+
+ private fun watchBars() {
+ androidx.core.view.ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { v, insets ->
+ if (insets.isVisible(WindowInsetsCompat.Type.navigationBars()) || insets.isVisible(WindowInsetsCompat.Type.statusBars())) {
+ v.removeCallbacks(rehideBars)
+ v.postDelayed(rehideBars, 1500)
+ }
+ androidx.core.view.ViewCompat.onApplyWindowInsets(v, insets)
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ hideBars()
+ }
+
+ private fun hideBars() {
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+ WindowInsetsControllerCompat(window, window.decorView).apply {
+ hide(WindowInsetsCompat.Type.systemBars())
+ systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+ }
+ }
+
+ override fun onWindowFocusChanged(hasFocus: Boolean) {
+ super.onWindowFocusChanged(hasFocus)
+ if (hasFocus) hideBars()
+ }
+
+ private var guideDown = false
+ private var guideDownAt = 0L
+ private var lastGuideUp = 0L
+ private var pendingNexus: Runnable? = null
+ private var swallowGuideUp = false
+
+ private fun isGuideKey(event: KeyEvent): Boolean = event.keyCode == KeyEvent.KEYCODE_BUTTON_MODE
+
+ /** One press is the stream's Xbox button; two quick presses or a hold open the player menu. */
+ private fun guideKey(event: KeyEvent): Boolean {
+ if (!isGuideKey(event)) return false
+ if (event.action == KeyEvent.ACTION_DOWN) {
+ if (event.repeatCount == 0) {
+ guideDown = true; guideDownAt = event.eventTime
+ val pending = pendingNexus
+ if (pending != null && event.eventTime - lastGuideUp <= DOUBLE_PRESS_MS) {
+ root.removeCallbacks(pending); pendingNexus = null; guideDown = false
+ swallowGuideUp = true
+ openMenu()
+ }
+ } else if (guideDown && event.eventTime - guideDownAt >= 600) {
+ guideDown = false
+ swallowGuideUp = true
+ openMenu()
+ }
+ } else if (event.action == KeyEvent.ACTION_UP && guideDown) {
+ guideDown = false
+ lastGuideUp = event.eventTime
+ val tap = Runnable { pendingNexus = null; pressNexus(1); root.postDelayed({ pressNexus(0) }, 150) }
+ pendingNexus = tap
+ root.postDelayed(tap, DOUBLE_PRESS_MS)
+ }
+ return true
+ }
+
+ private fun pressNexus(value: Int) {
+ web?.evaluateJavascript("window.__ddButton && window.__ddButton('Nexus', $value)", null)
+ }
+
+ // Back opens the player menu so a stray press never ends a stream.
+ override fun dispatchKeyEvent(event: KeyEvent): Boolean {
+ if (menu != null) {
+ if (isGuideKey(event)) {
+ if (event.action == KeyEvent.ACTION_UP) { if (swallowGuideUp) swallowGuideUp = false else closeMenu() }
+ return true
+ }
+ if (event.keyCode == KeyEvent.KEYCODE_BACK) {
+ if (event.action == KeyEvent.ACTION_UP) { if (menuPage.isNotEmpty()) menuPage = "" else closeMenu() }
+ return true
+ }
+ return super.dispatchKeyEvent(event)
+ }
+ if (notice == null && guideKey(event)) return true
+ if (event.keyCode == KeyEvent.KEYCODE_BACK) {
+ if (event.action == KeyEvent.ACTION_UP) { if (notice == null) openMenu() else finish() }
+ return true
+ }
+ // Reporting controller buttons handled stops Android turning an unused one into a fallback key.
+ val handled = super.dispatchKeyEvent(event)
+ val fromController = event.source and android.view.InputDevice.SOURCE_GAMEPAD == android.view.InputDevice.SOURCE_GAMEPAD ||
+ event.source and android.view.InputDevice.SOURCE_JOYSTICK == android.view.InputDevice.SOURCE_JOYSTICK
+ return handled || (notice == null && web != null && fromController)
+ }
+
+ private var menuPage by androidx.compose.runtime.mutableStateOf("")
+
+ private fun openMenu() {
+ if (menu != null || web == null) return
+ web?.evaluateJavascript("window.__ddInput && window.__ddInput(false)", null)
+ menuPage = ""
+ val regions = session?.regionList?.map { it.name }.orEmpty()
+ val view = androidx.compose.ui.platform.ComposeView(this)
+ view.setContent {
+ com.droiddeck.launcher.ui.DroidDeckTheme {
+ var current by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(settings) }
+ val entries = androidx.compose.runtime.remember { List(6) { androidx.compose.ui.focus.FocusRequester() } }
+ androidx.compose.runtime.LaunchedEffect(menuPage) {
+ if (menuPage.isEmpty()) runCatching { entries[lastMainEntry.coerceIn(0, 5)].requestFocus() }
+ }
+ val cs = androidx.compose.material3.MaterialTheme.colorScheme
+ androidx.compose.foundation.layout.Box(androidx.compose.ui.Modifier.fillMaxSize()) {
+ androidx.compose.foundation.layout.Column(
+ androidx.compose.ui.Modifier.fillMaxHeight().width(400.dp).background(cs.surface.copy(alpha = 0.95f))
+ .verticalScroll(androidx.compose.foundation.rememberScrollState()).padding(vertical = 12.dp),
+ ) {
+ androidx.compose.material3.Text(intent.getStringExtra("title").orEmpty().ifEmpty { "Xbox Cloud Gaming" },
+ style = androidx.compose.material3.MaterialTheme.typography.titleMedium, color = cs.onSurface,
+ modifier = androidx.compose.ui.Modifier.padding(horizontal = 16.dp, vertical = 4.dp))
+ if (menuPage.isEmpty()) {
+ MenuEntry("Resume", "Back to the game", entries[0], 0) { closeMenu() }
+ MenuEntry("Xbox menu", "The game's Xbox menu (also: Guide once, or View + Menu)", entries[1], 1) {
+ closeMenu(); web?.evaluateJavascript("window.__ddNexus && window.__ddNexus()", null)
+ }
+ MenuEntry("Screen effects", "Sharpen, colour, deband, FXAA, CRT, NTSC, Toon, fps cap", entries[2], 2) { menuPage = "picture" }
+ MenuEntry("Stream quality", "Resolution, region, language, codec, bitrate (next stream)", entries[3], 3) { menuPage = "stream" }
+ MenuEntry("Performance overlay and sound", "Overlay level and layout, volume, controller rumble", entries[4], 4) { menuPage = "overlay" }
+ MenuEntry("Leave", "End the stream", entries[5], 5) { finish() }
+ androidx.compose.material3.Text("Guide twice opens this menu; Back or Guide closes it.",
+ style = androidx.compose.material3.MaterialTheme.typography.bodySmall, color = cs.onSurfaceVariant,
+ modifier = androidx.compose.ui.Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
+ } else {
+ MenuEntry("โน Back", null) { menuPage = "" }
+ if (menuPage == "stream") MenuEntry("Restart the stream with these settings",
+ "Ends this cloud session and starts a new one; the game restarts, unsaved progress is lost.") { restartStream() }
+ val page = menuPage
+ XboxCloudSettingsPanel(current, regions, live = true, section = page, restoreRow = lastRow[page] ?: 0,
+ onRowFocused = { lastRow[page] = it }, startedWith = startedWith) { changed ->
+ current = changed
+ settings = changed
+ XboxCloudSettings.save(this@XboxCloudPlayerActivity, changed)
+ web?.evaluateJavascript("window.__ddApply && window.__ddApply(${JSONObject.quote(pageSettings(changed))})", null)
+ }
+ }
+ }
+ }
+ }
+ }
+ menu = view
+ root.addView(view, FrameLayout.LayoutParams(-1, -1))
+ view.requestFocus()
+ }
+
+ private fun pageSettings(s: XboxCloudSettings): String = JSONObject(s.toJson())
+ .put("server", session?.let { active -> active.regionList.firstOrNull { it.baseUri == active.baseUri }?.name } ?: "").toString()
+
+ private var lastMainEntry = 0
+ private val lastRow = mutableMapOf()
+
+ @androidx.compose.runtime.Composable
+ private fun MenuEntry(title: String, note: String?, focus: androidx.compose.ui.focus.FocusRequester? = null, index: Int = -1, onClick: () -> Unit) =
+ XboxMenuEntry(title, note, focus, { if (index >= 0) lastMainEntry = index }, onClick)
+
+ private fun restartStream() {
+ val active = session; val path = sessionPath
+ sessionPath = null
+ closeMenu()
+ showNotice("Restarting the streamโฆ", withButtons = false)
+ Thread({
+ if (active != null && path != null) runCatching { XboxCloudSession.request(active, "DELETE", "/$path") }
+ .onSuccess { diag("cloud player restart: session closed ${it.status}") }
+ runOnUiThread { if (!isFinishing) { startActivity(playerIntent(this, intent.getStringExtra("product").orEmpty(), intent.getStringExtra("title").orEmpty())); finish() } }
+ }, "xcloud-restart").start()
+ }
+
+ private fun closeMenu() {
+ menu?.let { root.removeView(it) }
+ menu = null
+ web?.evaluateJavascript("window.__ddInput && window.__ddInput(true)", null)
+ web?.requestFocus()
+ }
+
+ override fun onDestroy() {
+ closed = true
+ web?.let { it.evaluateJavascript("window.__ddStop && window.__ddStop()", null); root.removeView(it); it.destroy() }
+ web = null
+ val active = session; val path = sessionPath
+ if (active != null && path != null) Thread({
+ runCatching { XboxCloudSession.request(active, "DELETE", "/$path") }
+ .onSuccess { diag("cloud player session closed: ${it.status}") }
+ }, "xcloud-stop").start()
+ worker.shutdownNow(); signalling.shutdownNow()
+ super.onDestroy()
+ }
+
+ private fun diag(text: String) = StoresState.logLine("Xbox: $text")
+
+ companion object {
+ private const val DOUBLE_PRESS_MS = 350L
+
+ fun intent(context: Context, productId: String, title: String): Intent = Intent(context, XboxCloudPlayerActivity::class.java)
+ .putExtra("product", productId.uppercase()).putExtra("title", title)
+
+ /** The player the settings pick: the native engine (frame generation) where it can run, else the web player. */
+ fun playerIntent(context: Context, productId: String, title: String): Intent {
+ val native = XboxNativePlayerActivity.supported() && XboxCloudSettings.load(context).playerEngine == "native"
+ return if (!native) intent(context, productId, title) else Intent(context, XboxNativePlayerActivity::class.java)
+ .putExtra("product", productId.uppercase()).putExtra("title", title)
+ }
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSession.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSession.kt
new file mode 100644
index 000000000..ca2120278
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSession.kt
@@ -0,0 +1,120 @@
+package com.droiddeck.launcher.stores.xbox
+
+import android.content.Context
+import org.json.JSONArray
+import org.json.JSONObject
+import java.net.HttpURLConnection
+import java.net.URL
+
+/** Streaming-service tokens stay in memory only. Request shapes follow Greenlight / xal-node by unknownskl (MIT). */
+internal object XboxCloudSession {
+ class Region(val name: String, val baseUri: String, val isDefault: Boolean)
+
+ class Session(val account: String, val token: String, val offering: String, val baseUri: String, val regionList: List,
+ val userToken: String?, internal val expiresAt: Long,
+ val osName: String = "windows") {
+ val fresh get() = System.currentTimeMillis() < expiresAt - 5 * 60_000L
+ val regions get() = regionList.size
+
+ fun forStream(regionName: String, os: String): Session = Session(account, token, offering,
+ regionList.firstOrNull { it.name == regionName }?.baseUri ?: baseUri, regionList, userToken, expiresAt, os)
+ }
+
+ @Volatile private var cached: Session? = null
+
+ /** One fetch at a time (about 20 s: two ticket exchanges); a second caller waits and gets the same tokens. */
+ @Synchronized
+ fun get(app: Context, account: String): Session {
+ cached?.takeIf { it.account == account && it.fresh }?.let { return it }
+ val json = Xodus.run(app, listOf("cloud-token"), timeout = 60)
+ require(json.getInt("schema") == 1 && json.getString("account") == account)
+ val array = json.getJSONArray("regions")
+ val regions = (0 until array.length()).map { array.getJSONObject(it) }
+ .map { Region(it.getString("name"), it.getString("baseUri"), it.optBoolean("isDefault")) }
+ val default = regions.firstOrNull { it.isDefault } ?: regions.firstOrNull() ?: error("No Xbox Cloud Gaming region")
+ val seconds = json.optLong("durationInSeconds").coerceIn(0, 24 * 3600)
+ return Session(account, json.getString("gsToken"), json.getString("offering"), default.baseUri, regions,
+ json.optString("userToken").takeIf { !json.isNull("userToken") && it.isNotEmpty() }, System.currentTimeMillis() + seconds * 1000).also { cached = it }
+ }
+
+ fun clear() { cached = null; titleIds = null }
+
+ /**
+ * For the native player's own process: the main process's tokens (it keeps them, and fetches them ahead while a
+ * cloud game's page is open), handed over in memory through XboxCloudTokenService; null if that is not reachable.
+ */
+ fun fromMainProcess(app: Context, account: String, timeoutSeconds: Long = 90): Session? {
+ cached?.takeIf { it.account == account && it.fresh }?.let { return it }
+ val reply = XboxCloudTokenService.request(app, account, timeoutSeconds) ?: return null
+ reply.getString("error")?.let { error(it) }
+ return fromBundle(reply).takeIf { it.account == account && it.fresh }?.also { cached = it }
+ }
+
+ internal fun toBundle(s: Session) = android.os.Bundle().apply {
+ putString("account", s.account); putString("token", s.token); putString("offering", s.offering)
+ putString("baseUri", s.baseUri); putString("userToken", s.userToken); putLong("expiresAt", s.expiresAt)
+ putStringArrayList("regionNames", ArrayList(s.regionList.map { it.name }))
+ putStringArrayList("regionUris", ArrayList(s.regionList.map { it.baseUri }))
+ putBooleanArray("regionDefaults", s.regionList.map { it.isDefault }.toBooleanArray())
+ }
+
+ internal fun fromBundle(b: android.os.Bundle): Session {
+ val names = b.getStringArrayList("regionNames").orEmpty()
+ val uris = b.getStringArrayList("regionUris").orEmpty()
+ val defaults = b.getBooleanArray("regionDefaults") ?: BooleanArray(0)
+ val regions = names.indices.map { Region(names[it], uris.getOrElse(it) { "" }, defaults.getOrElse(it) { false }) }
+ return Session(b.getString("account").orEmpty(), b.getString("token").orEmpty(), b.getString("offering").orEmpty(),
+ b.getString("baseUri").orEmpty(), regions, b.getString("userToken"), b.getLong("expiresAt"))
+ }
+
+ @Volatile private var titleIds: Pair>? = null
+
+ fun titles(session: Session): Map {
+ titleIds?.takeIf { it.first == session.account }?.let { return it.second }
+ val reply = request(session, "GET", "/v2/titles")
+ check(reply.status == 200) { "Xbox Cloud Gaming answered ${reply.status}" }
+ val results = JSONObject(reply.body).optJSONArray("results") ?: JSONArray()
+ val map = (0 until results.length()).mapNotNull { index ->
+ val row = results.optJSONObject(index) ?: return@mapNotNull null
+ val product = row.optJSONObject("details")?.optString("productId")?.uppercase().orEmpty()
+ val title = row.optString("titleId")
+ if (product.matches(Regex("[A-Z0-9]{12}")) && title.isNotBlank() && title.length <= 64) product to title else null
+ }.toMap()
+ titleIds = session.account to map
+ return map
+ }
+
+ class Reply(val status: Int, val body: String)
+
+ fun request(session: Session, method: String, path: String, body: String? = null): Reply {
+ val connection = URL(session.baseUri + path).openConnection() as HttpURLConnection
+ try {
+ connection.connectTimeout = 15_000; connection.readTimeout = 30_000
+ connection.requestMethod = method
+ connection.setRequestProperty("Authorization", "Bearer ${session.token}")
+ connection.setRequestProperty("Accept", "application/json")
+ connection.setRequestProperty("Content-Type", "application/json")
+ connection.setRequestProperty("X-Gssv-Client", "XboxComBrowser")
+ connection.setRequestProperty("X-MS-Device-Info", deviceInfo(session.osName))
+ if (body != null) {
+ connection.doOutput = true
+ connection.outputStream.use { it.write(body.toByteArray(Charsets.UTF_8)) }
+ }
+ val status = connection.responseCode
+ val stream = if (status in 200..299) connection.inputStream else connection.errorStream
+ val text = stream?.use { it.readBytes() }?.also { require(it.size <= 16_000_000) } ?: ByteArray(0)
+ return Reply(status, String(text, Charsets.UTF_8))
+ } finally { connection.disconnect() }
+ }
+
+ private fun deviceInfo(os: String) = JSONObject()
+ .put("appInfo", JSONObject().put("env", JSONObject().put("clientAppId", "www.xbox.com").put("clientAppType", "browser")
+ .put("clientAppVersion", "21.1.98").put("clientSdkVersion", "8.5.3").put("httpEnvironment", "prod").put("sdkInstallId", "")))
+ .put("dev", JSONObject()
+ .put("hw", JSONObject().put("make", "Microsoft").put("model", "unknown").put("sdktype", "web"))
+ .put("os", JSONObject().put("name", os).put("ver", "22631.2715").put("platform", "desktop"))
+ .put("displayInfo", JSONObject().put("dimensions", JSONObject().put("widthInPixels", 1920).put("heightInPixels", 1080))
+ .put("pixelDensity", JSONObject().put("dpiX", 2).put("dpiY", 2)))
+ .put("browser", JSONObject().put("browserName", "chrome").put("browserVersion", "119.0")))
+ .toString()
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSettings.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSettings.kt
new file mode 100644
index 000000000..7b8806a36
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSettings.kt
@@ -0,0 +1,108 @@
+package com.droiddeck.launcher.stores.xbox
+
+import android.content.Context
+import org.json.JSONObject
+
+data class XboxCloudSettings(
+ val resolution: String = "1080p", // 720p | 1080p | 1080p_hq
+ val region: String = "", // "" = Microsoft's choice
+ val language: String = "", // "" = the device's language
+ val codec: String = "default", // default | high | normal | low
+ val bitrateMbps: Int = 0, // 0 = no cap
+ val preventDrops: Boolean = false,
+ val statsLevel: Int = 0, // 0 off, 1 fps, 2 basic, 3 detailed, 4 full
+ val statsLayout: String = "horizontal",
+ val statsCorner: String = "right", // right | left
+ val volume: Int = 100, // 100..300 %
+ val rumble: Int = 100, // 0..100 %
+ val maxFps: Int = 0,
+ val brightness: Int = 0,
+ val contrast: Int = 0,
+ val gamma: Float = 1f,
+ val saturation: Int = 100,
+ val sharpen: Int = 0,
+ val sharpenMode: String = "cas", // cas | usm
+ val deband: Int = 0,
+ val fxaa: Boolean = false,
+ val toon: Boolean = false,
+ val crt: Boolean = false,
+ val ntsc: Boolean = false,
+ val playerEngine: String = "web", // web | native (WebRTC library + DroidDeck's compositor, frame generation)
+ val frameGen: String = "ddfg", // native engine: off | ddfg (2x) | lsfg | lsfg3 | lsfg4 (needs Lossless.dll)
+ val fpsLimit: Int = 0, // native engine target: 0 = the screen's rate, else 30/45/60/120/144/165
+ val fpsCustomOn: Boolean = false, // use fpsCustom instead of the presets
+ val fpsCustom: Int = 60, // 10..165
+ val instantInput: Boolean = true, // native: send controller changes at once (else every 16 ms)
+ val smallBuffer: Boolean = false, // native: WebRTC shows frames as soon as decoded (no playout delay)
+ val fastDecoder: Boolean = true, // native: the hardware decoder's low-latency mode
+ val zeroCopy: Boolean = false, // native, frame generation off: decode straight onto the screen
+) {
+ /** The native engine's frame-rate target (0 = the screen's own rate). */
+ val fpsTarget get() = if (fpsCustomOn) fpsCustom else fpsLimit
+
+ /** The service picks the stream resolution from the reported OS (Better xCloud's mapping). */
+ val osName get() = when (resolution) { "720p" -> "android"; "1080p_hq" -> "tizen"; else -> "windows" }
+
+ fun toJson(): String = JSONObject()
+ .put("resolution", resolution).put("region", region).put("language", language).put("codec", codec)
+ .put("bitrateMbps", bitrateMbps).put("preventDrops", preventDrops).put("statsLevel", statsLevel).put("statsLayout", statsLayout).put("statsCorner", statsCorner)
+ .put("volume", volume).put("rumble", rumble).put("maxFps", maxFps).put("brightness", brightness).put("contrast", contrast)
+ .put("gamma", gamma.toDouble()).put("saturation", saturation).put("sharpen", sharpen).put("sharpenMode", sharpenMode)
+ .put("deband", deband).put("fxaa", fxaa).put("toon", toon).put("crt", crt).put("ntsc", ntsc)
+ .put("playerEngine", playerEngine).put("frameGen", frameGen)
+ .put("fpsLimit", fpsLimit).put("fpsCustomOn", fpsCustomOn).put("fpsCustom", fpsCustom)
+ .put("instantInput", instantInput).put("smallBuffer", smallBuffer).put("fastDecoder", fastDecoder).put("zeroCopy", zeroCopy)
+ .toString()
+
+ fun withoutEffects() = copy(brightness = 0, contrast = 0, gamma = 1f, saturation = 100, sharpen = 0, sharpenMode = "cas",
+ deband = 0, fxaa = false, toon = false, crt = false, ntsc = false)
+
+ companion object {
+ private const val PREFS = "xbox_cloud"
+ private const val KEY = "settings"
+
+ fun load(context: Context): XboxCloudSettings =
+ decode(context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).getString(KEY, null))
+
+ internal fun decode(text: String?): XboxCloudSettings = runCatching {
+ val json = JSONObject(text ?: return XboxCloudSettings())
+ val d = XboxCloudSettings()
+ XboxCloudSettings(
+ resolution = json.optString("resolution", d.resolution).takeIf { it in setOf("720p", "1080p", "1080p_hq") } ?: d.resolution,
+ region = json.optString("region", d.region).take(64), language = json.optString("language", d.language).take(16),
+ codec = json.optString("codec", d.codec).takeIf { it in setOf("default", "high", "normal", "low") } ?: d.codec,
+ bitrateMbps = json.optInt("bitrateMbps", d.bitrateMbps).coerceIn(0, 100),
+ preventDrops = json.optBoolean("preventDrops", d.preventDrops),
+ statsLevel = json.optInt("statsLevel", d.statsLevel).coerceIn(0, 4),
+ statsLayout = json.optString("statsLayout", d.statsLayout).takeIf { it == "vertical" } ?: "horizontal",
+ statsCorner = json.optString("statsCorner", d.statsCorner).takeIf { it == "left" } ?: "right",
+ volume = json.optInt("volume", d.volume).coerceIn(100, 300), rumble = json.optInt("rumble", d.rumble).coerceIn(0, 100),
+ maxFps = json.optInt("maxFps", d.maxFps).coerceIn(0, 120),
+ brightness = json.optInt("brightness", d.brightness).coerceIn(-50, 50), contrast = json.optInt("contrast", d.contrast).coerceIn(-100, 100),
+ gamma = json.optDouble("gamma", 1.0).toFloat().coerceIn(0.5f, 3f), saturation = json.optInt("saturation", d.saturation).coerceIn(0, 200),
+ sharpen = json.optInt("sharpen", d.sharpen).coerceIn(0, 10),
+ sharpenMode = json.optString("sharpenMode", d.sharpenMode).takeIf { it == "usm" } ?: "cas",
+ deband = json.optInt("deband", d.deband).coerceIn(0, 8),
+ fxaa = json.optBoolean("fxaa"), toon = json.optBoolean("toon"), crt = json.optBoolean("crt"), ntsc = json.optBoolean("ntsc"),
+ playerEngine = json.optString("playerEngine", d.playerEngine).takeIf { it == "native" } ?: "web",
+ frameGen = json.optString("frameGen", d.frameGen).let { if (it == "winfg") "ddfg" else it }
+ .takeIf { it in setOf("off", "ddfg", "lsfg", "lsfg3", "lsfg4") } ?: d.frameGen,
+ fpsLimit = json.optInt("fpsLimit", d.fpsLimit).takeIf { it in setOf(0, 30, 45, 60, 120, 144, 165) } ?: 0,
+ fpsCustomOn = json.optBoolean("fpsCustomOn", d.fpsCustomOn),
+ fpsCustom = json.optInt("fpsCustom", d.fpsCustom).coerceIn(10, 165),
+ instantInput = json.optBoolean("instantInput", d.instantInput), smallBuffer = json.optBoolean("smallBuffer", d.smallBuffer),
+ fastDecoder = json.optBoolean("fastDecoder", d.fastDecoder), zeroCopy = json.optBoolean("zeroCopy", d.zeroCopy),
+ )
+ }.getOrDefault(XboxCloudSettings())
+
+ fun save(context: Context, settings: XboxCloudSettings) {
+ context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().putString(KEY, settings.toJson()).apply()
+ }
+
+ val LANGUAGES = listOf("" to "Device language", "en-US" to "English (US)", "en-GB" to "English (UK)", "de-DE" to "Deutsch",
+ "es-ES" to "Espaรฑol (Espaรฑa)", "es-MX" to "Espaรฑol (Mรฉxico)", "fr-FR" to "Franรงais", "it-IT" to "Italiano",
+ "ja-JP" to "ๆฅๆฌ่ช", "ko-KR" to "ํ๊ตญ์ด", "nl-NL" to "Nederlands", "pl-PL" to "Polski", "pt-BR" to "Portuguรชs (Brasil)",
+ "pt-PT" to "Portuguรชs (Portugal)", "ru-RU" to "ะ ัััะบะธะน", "sv-SE" to "Svenska", "tr-TR" to "Tรผrkรงe", "zh-CN" to "ไธญๆ (็ฎไฝ)",
+ "zh-TW" to "ไธญๆ (็น้ซ)")
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSettingsActivity.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSettingsActivity.kt
new file mode 100644
index 000000000..bfe1e3908
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSettingsActivity.kt
@@ -0,0 +1,60 @@
+package com.droiddeck.launcher.stores.xbox
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Text
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import com.droiddeck.launcher.stores.Store
+import com.droiddeck.launcher.stores.StoreAccounts
+import com.droiddeck.launcher.ui.DroidDeckTheme
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+class XboxCloudSettingsActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent {
+ DroidDeckTheme {
+ var settings by remember { mutableStateOf(XboxCloudSettings.load(this)) }
+ var regions by remember { mutableStateOf(emptyList()) }
+ LaunchedEffect(Unit) {
+ regions = withContext(Dispatchers.IO) {
+ runCatching {
+ val account = StoreAccounts.signedInAs(applicationContext, Store.XBOX) ?: return@runCatching emptyList()
+ XboxCloudSession.get(applicationContext, account).regionList.map { it.name }
+ }.getOrDefault(emptyList())
+ }
+ }
+ Column(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background).verticalScroll(rememberScrollState())) {
+ Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
+ OutlinedButton(onClick = { finish() }) { Text("Back") }
+ Text("Xbox Cloud Gaming", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.padding(horizontal = 16.dp))
+ }
+ XboxCloudSettingsPanel(settings, regions, live = false) { changed ->
+ settings = changed
+ XboxCloudSettings.save(this@XboxCloudSettingsActivity, changed)
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSettingsPanel.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSettingsPanel.kt
new file mode 100644
index 000000000..0a09fd789
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSettingsPanel.kt
@@ -0,0 +1,313 @@
+package com.droiddeck.launcher.stores.xbox
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.foundation.border
+import androidx.compose.foundation.background
+import androidx.compose.foundation.focusable
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.runtime.rememberUpdatedState
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.compositionLocalOf
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusProperties
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+
+/** Shared by the Xbox cloud settings page and the in-player menu; [live] changes reach the running stream. */
+@Composable
+fun XboxCloudSettingsPanel(
+ settings: XboxCloudSettings,
+ regions: List,
+ live: Boolean,
+ /** "stream", "picture" or "overlay" shows that section only; null shows all. */
+ section: String? = null,
+ /** The row to select when the panel opens (the last one used), or null for no initial selection. */
+ restoreRow: Int? = null,
+ onRowFocused: (Int) -> Unit = {},
+ /** The settings the running stream started with; rows that differ say they need a restart (null: no stream). */
+ startedWith: XboxCloudSettings? = null,
+ onChange: (XboxCloudSettings) -> Unit,
+) {
+ val focus = remember(section) { PanelFocus() }
+ focus.next = 0
+ focus.onFocused = onRowFocused
+ CompositionLocalProvider(LocalPanelFocus provides focus) {
+ PanelContent(settings, regions, live, section, startedWith, onChange)
+ }
+ LaunchedEffect(section) {
+ if (restoreRow != null) runCatching { focus.requester(restoreRow.coerceIn(0, (focus.count - 1).coerceAtLeast(0))).requestFocus() }
+ }
+}
+
+/** Gives each settings row its place in the page, so the last-used row can be selected again. */
+private class PanelFocus {
+ var next = 0
+ var count = 0
+ var onFocused: (Int) -> Unit = {}
+ private val requesters = mutableMapOf()
+ fun requester(index: Int) = requesters.getOrPut(index) { FocusRequester() }
+ fun take(): Int = next++.also { count = maxOf(count, next) }
+}
+
+private val LocalPanelFocus = compositionLocalOf { null }
+
+@Composable
+private fun PanelContent(
+ settings: XboxCloudSettings,
+ regions: List,
+ live: Boolean,
+ section: String?,
+ startedWith: XboxCloudSettings?,
+ onChange: (XboxCloudSettings) -> Unit,
+) {
+ // Stream-start settings changed during a stream wait for a restart; back at the starting value, no note.
+ fun pending(differs: (XboxCloudSettings) -> Boolean) = startedWith != null && differs(startedWith)
+
+ if (section == null || section == "stream") Section("Stream", if (live) "Applies to the next stream." else "Applies when a stream starts.") {
+ if (XboxNativePlayerActivity.supported()) {
+ Choice("Player engine", listOf("web" to "Web", "native" to "Native (frame gen)"), settings.playerEngine,
+ pending { it.playerEngine != settings.playerEngine }) { onChange(settings.copy(playerEngine = it)) }
+ val ctx = androidx.compose.ui.platform.LocalContext.current
+ val lsfg = androidx.compose.runtime.remember { XboxNativeVideo.lsfgReady(ctx) }
+ Choice("Frame generation", XboxNativeVideo.modes(lsfg).map { it to XboxNativeVideo.modeName(it) }, settings.frameGen) {
+ onChange(settings.copy(frameGen = it))
+ }
+ Text("Native engine (experimental): Google's WebRTC and DroidDeck's own compositor, so DroidDeck frame generation " +
+ "(DDFG) or LSFG can add frames. LSFG uses the Lossless.dll DroidDeck already uses for games. Screen effects " +
+ "apply to the web engine.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(start = 8.dp, bottom = 6.dp))
+ }
+ // The service picks the size from the reported device profile (the PC profile can send 1440p).
+ Choice("Resolution", listOf("720p" to "720p (phone)", "1080p" to "Best (PC)", "1080p_hq" to "1080p HQ (TV)"),
+ settings.resolution, pending { it.resolution != settings.resolution }) { onChange(settings.copy(resolution = it)) }
+ Choice("Server region", listOf("" to "Automatic") + regions.map { it to it }, settings.region,
+ pending { it.region != settings.region }) { onChange(settings.copy(region = it)) }
+ Choice("Game language", XboxCloudSettings.LANGUAGES, settings.language,
+ pending { it.language != settings.language }) { onChange(settings.copy(language = it)) }
+ Choice("Video codec quality", listOf("default" to "Automatic", "high" to "High", "normal" to "Normal", "low" to "Low"), settings.codec,
+ pending { it.codec != settings.codec }) { onChange(settings.copy(codec = it)) }
+ Choice("Bitrate limit", listOf(0 to "No limit") + listOf(5, 8, 10, 12, 15, 20, 25, 30, 40, 50).map { it to "$it Mb/s" }, settings.bitrateMbps,
+ pending { it.bitrateMbps != settings.bitrateMbps }) { onChange(settings.copy(bitrateMbps = it)) }
+ Toggle("Stop quality drops", "Keeps the resolution up when this device decodes slowly.", settings.preventDrops) { onChange(settings.copy(preventDrops = it)) }
+ }
+
+ if (section == null || section == "picture") Section("Picture", "Shader effects on the stream.") {
+ Step("Brightness", "${settings.brightness}", { onChange(settings.copy(brightness = (settings.brightness - 5).coerceAtLeast(-50))) },
+ { onChange(settings.copy(brightness = (settings.brightness + 5).coerceAtMost(50))) })
+ Step("Contrast", "${settings.contrast}", { onChange(settings.copy(contrast = (settings.contrast - 10).coerceAtLeast(-100))) },
+ { onChange(settings.copy(contrast = (settings.contrast + 10).coerceAtMost(100))) })
+ Step("Gamma", String.format("%.1f", settings.gamma), { onChange(settings.copy(gamma = ((settings.gamma * 10).toInt() - 1).coerceAtLeast(5) / 10f)) },
+ { onChange(settings.copy(gamma = ((settings.gamma * 10).toInt() + 1).coerceAtMost(30) / 10f)) })
+ Step("Saturation", "${settings.saturation}%", { onChange(settings.copy(saturation = (settings.saturation - 10).coerceAtLeast(0))) },
+ { onChange(settings.copy(saturation = (settings.saturation + 10).coerceAtMost(200))) })
+ Step("Sharpen", if (settings.sharpen == 0) "Off" else "${settings.sharpen}", { onChange(settings.copy(sharpen = (settings.sharpen - 1).coerceAtLeast(0))) },
+ { onChange(settings.copy(sharpen = (settings.sharpen + 1).coerceAtMost(10))) })
+ Choice("Sharpen type", listOf("cas" to "Contrast-adaptive", "usm" to "Unsharp mask"), settings.sharpenMode) { onChange(settings.copy(sharpenMode = it)) }
+ Step("Deband", if (settings.deband == 0) "Off" else "${settings.deband}", { onChange(settings.copy(deband = (settings.deband - 1).coerceAtLeast(0))) },
+ { onChange(settings.copy(deband = (settings.deband + 1).coerceAtMost(8))) })
+ Toggle("FXAA", "Smooths jagged edges.", settings.fxaa) { onChange(settings.copy(fxaa = it)) }
+ Toggle("CRT", "Scanlines and colour fringes.", settings.crt) { onChange(settings.copy(crt = it)) }
+ Toggle("NTSC", "Old TV signal look.", settings.ntsc) { onChange(settings.copy(ntsc = it)) }
+ Toggle("Toon", "Cartoon outlines.", settings.toon) { onChange(settings.copy(toon = it)) }
+ Choice("Frame-rate cap", listOf(0 to "Off", 30 to "30 fps", 45 to "45 fps", 60 to "60 fps"), settings.maxFps) { onChange(settings.copy(maxFps = it)) }
+ Row(Modifier.fillMaxWidth().padding(top = 6.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ OutlinedButton(onClick = { onChange(settings.withoutEffects()) }) { Text("Reset picture") }
+ }
+ Text("Effects cost a little GPU time; with none set the stream is shown directly.", style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 4.dp))
+ }
+
+ if ((section == null && XboxNativePlayerActivity.supported()) || section == "framerate") Section("Frame rate (native engine)",
+ "With frame generation this is the rate shown; without it, only targets below the stream's 60 fps change anything.") {
+ Choice("Target", listOf(0 to "Screen's rate", 30 to "30 fps", 45 to "45 fps", 60 to "60 fps", 120 to "120 fps", 144 to "144 fps", 165 to "165 fps"),
+ settings.fpsLimit) { onChange(settings.copy(fpsLimit = it, fpsCustomOn = false)) }
+ Toggle("Custom frame rate", "Set any rate from 10 to 165 fps with the slider.", settings.fpsCustomOn) { onChange(settings.copy(fpsCustomOn = it)) }
+ if (settings.fpsCustomOn) SliderRow("Custom", settings.fpsCustom, 10, 165) { onChange(settings.copy(fpsCustom = it)) }
+ }
+
+ if ((section == null && XboxNativePlayerActivity.supported()) || section == "latency") Section("Low latency (native engine)", null) {
+ Toggle("Instant controller input", "Sends each press the moment it happens instead of every 16 ms.", settings.instantInput) {
+ onChange(settings.copy(instantInput = it))
+ }
+ Toggle("Small network buffer", "Shows frames as soon as they are decoded. Less delay; may stutter on weak Wi-Fi.",
+ settings.smallBuffer, pending { it.smallBuffer != settings.smallBuffer }) { onChange(settings.copy(smallBuffer = it)) }
+ Toggle("Fast decoder", "The video chip's low-latency mode.", settings.fastDecoder,
+ pending { it.fastDecoder != settings.fastDecoder }) { onChange(settings.copy(fastDecoder = it)) }
+ Toggle("Zero-copy picture", "Frames go straight from the video chip to the screen: the lowest delay. Used while frame generation is off and no target below 60 fps is set; switches live.",
+ settings.zeroCopy) { onChange(settings.copy(zeroCopy = it)) }
+ }
+
+ // "overlay-native": the native player's page (its sound has no volume boost).
+ if (section == null || section == "overlay" || section == "overlay-native") Section("Overlay and sound", null) {
+ Choice("Performance overlay", listOf(0 to "Off", 1 to "FPS only", 2 to "Basic", 3 to "Detailed", 4 to "Full"), settings.statsLevel) {
+ onChange(settings.copy(statsLevel = it))
+ }
+ Choice("Overlay layout", listOf("horizontal" to "Horizontal", "vertical" to "Vertical"), settings.statsLayout) { onChange(settings.copy(statsLayout = it)) }
+ Choice("Overlay position", listOf("right" to "Top right", "left" to "Top left"), settings.statsCorner) { onChange(settings.copy(statsCorner = it)) }
+ Text("Basic: fps, ping, resolution. Detailed: + bitrate, decode time, packet loss. Full: + codec, jitter, " +
+ "dropped frames, server.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ if (section != "overlay-native") Step("Volume", "${settings.volume}%", { onChange(settings.copy(volume = (settings.volume - 25).coerceAtLeast(100))) },
+ { onChange(settings.copy(volume = (settings.volume + 25).coerceAtMost(300))) })
+ Step("Controller rumble", if (settings.rumble == 0) "Off" else "${settings.rumble}%", { onChange(settings.copy(rumble = (settings.rumble - 25).coerceAtLeast(0))) },
+ { onChange(settings.copy(rumble = (settings.rumble + 25).coerceAtMost(100))) })
+ }
+}
+
+@Composable
+private fun Section(title: String, note: String?, content: @Composable ColumnScope.() -> Unit) {
+ Column(Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp)) {
+ Text(title, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary)
+ if (note != null) Text(note, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Spacer(Modifier.height(4.dp))
+ content()
+ }
+}
+
+/**
+ * One settings row, driven by a controller: it takes focus as a whole (highlighted), Left / Right change its
+ * value, A / Enter selects; the โ / + buttons and switches inside stay for touch only.
+ */
+@Composable
+private fun SettingRow(
+ onLeft: (() -> Unit)?,
+ onRight: (() -> Unit)?,
+ onSelect: (() -> Unit)?,
+ content: @Composable RowScope.() -> Unit,
+) {
+ val panel = LocalPanelFocus.current
+ val index = panel?.take() ?: -1
+ var focused by remember { mutableStateOf(false) }
+ val currentSelect by rememberUpdatedState(onSelect)
+ val cs = MaterialTheme.colorScheme
+ val shape = RoundedCornerShape(10.dp)
+ Row(
+ Modifier.fillMaxWidth().padding(vertical = 1.dp).clip(shape)
+ .background(if (focused) cs.primary.copy(alpha = 0.16f) else Color.Transparent)
+ .border(2.dp, if (focused) cs.primary else Color.Transparent, shape)
+ .let { if (panel != null && index >= 0) it.focusRequester(panel.requester(index)) else it }
+ .onFocusChanged { state -> focused = state.isFocused; if (state.isFocused && index >= 0) panel?.onFocused?.invoke(index) }
+ .onKeyEvent { event ->
+ if (event.type != KeyEventType.KeyDown) return@onKeyEvent false
+ val action = when (event.key) {
+ Key.DirectionLeft -> onLeft
+ Key.DirectionRight -> onRight
+ Key.Enter, Key.NumPadEnter, Key.DirectionCenter, Key.ButtonA -> onSelect
+ else -> null
+ }
+ if (action != null) { action(); true } else false
+ }
+ .focusable()
+ // A tap selects too, without a second focus stop (a clickable would be one).
+ .pointerInput(Unit) { detectTapGestures { currentSelect?.invoke() } }
+ .padding(horizontal = 8.dp, vertical = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ content = content,
+ )
+}
+
+private val noFocus = Modifier.focusProperties { canFocus = false }
+
+@Composable
+private fun Step(label: String, value: String, onMinus: () -> Unit, onPlus: () -> Unit) {
+ SettingRow(onLeft = onMinus, onRight = onPlus, onSelect = onPlus) {
+ Text(label, Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface)
+ OutlinedButton(onClick = onMinus, modifier = noFocus.size(40.dp), contentPadding = PaddingValues(0.dp)) { Text("โ") }
+ Text(value, Modifier.widthIn(min = 112.dp, max = 168.dp).padding(horizontal = 4.dp), textAlign = TextAlign.Center,
+ style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ OutlinedButton(onClick = onPlus, modifier = noFocus.size(40.dp), contentPadding = PaddingValues(0.dp)) { Text("+") }
+ }
+}
+
+/** A value slider (touch) that a controller moves with Left / Right in steps of 1 (hold to repeat). */
+@Composable
+private fun SliderRow(label: String, value: Int, min: Int, max: Int, onValue: (Int) -> Unit) {
+ SettingRow(onLeft = { onValue((value - 1).coerceAtLeast(min)) }, onRight = { onValue((value + 1).coerceAtMost(max)) }, onSelect = null) {
+ Text(label, Modifier.width(72.dp), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface)
+ androidx.compose.material3.Slider(value = value.toFloat(), onValueChange = { onValue(it.toInt().coerceIn(min, max)) },
+ valueRange = min.toFloat()..max.toFloat(), modifier = noFocus.weight(1f))
+ Text("$value fps", Modifier.width(72.dp), textAlign = TextAlign.End, style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface)
+ }
+}
+
+@Composable
+private fun Choice(label: String, options: List>, selected: T, needsRestart: Boolean = false, onSelect: (T) -> Unit) {
+ val index = options.indexOfFirst { it.first == selected }.coerceAtLeast(0)
+ Step(label, options.getOrNull(index)?.second ?: "",
+ { if (options.isNotEmpty()) onSelect(options[(index - 1 + options.size) % options.size].first) },
+ { if (options.isNotEmpty()) onSelect(options[(index + 1) % options.size].first) })
+ if (needsRestart) Text("Takes effect when you restart the stream.", style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.tertiary, modifier = Modifier.padding(start = 8.dp, bottom = 4.dp))
+}
+
+@Composable
+private fun Toggle(label: String, note: String, checked: Boolean, needsRestart: Boolean = false, onToggle: (Boolean) -> Unit) {
+ SettingRow(onLeft = { onToggle(false) }, onRight = { onToggle(true) }, onSelect = { onToggle(!checked) }) {
+ Column(Modifier.weight(1f)) {
+ Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface)
+ Text(note, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ Spacer(Modifier.width(8.dp))
+ Switch(checked = checked, onCheckedChange = onToggle, modifier = noFocus)
+ }
+ if (needsRestart) Text("Takes effect when you restart the stream.", style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.tertiary, modifier = Modifier.padding(start = 8.dp, bottom = 4.dp))
+}
+
+/** One entry of the cloud players' menu list (highlighted when selected; A or a tap opens it). */
+@Composable
+internal fun XboxMenuEntry(title: String, note: String?, focus: FocusRequester? = null, onFocused: () -> Unit = {}, onClick: () -> Unit) {
+ val cs = MaterialTheme.colorScheme
+ var focused by remember { mutableStateOf(false) }
+ val shape = RoundedCornerShape(10.dp)
+ androidx.compose.material3.Surface(
+ onClick = onClick, color = if (focused) cs.primary.copy(alpha = 0.2f) else cs.surfaceContainerHigh, shape = shape,
+ border = if (focused) androidx.compose.foundation.BorderStroke(2.dp, cs.primary) else null,
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 3.dp)
+ .let { if (focus != null) it.focusRequester(focus) else it }
+ .onFocusChanged { state -> focused = state.isFocused; if (state.isFocused) onFocused() }
+ .onKeyEvent { event -> if (event.type == KeyEventType.KeyDown && event.key == Key.ButtonA) { onClick(); true } else false },
+ ) {
+ Column(Modifier.padding(horizontal = 14.dp, vertical = 10.dp)) {
+ Text(title, style = MaterialTheme.typography.titleSmall, color = cs.onSurface)
+ if (note != null) Text(note, style = MaterialTheme.typography.bodySmall, color = cs.onSurfaceVariant)
+ }
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSignalling.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSignalling.kt
new file mode 100644
index 000000000..a5e7587af
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudSignalling.kt
@@ -0,0 +1,74 @@
+package com.droiddeck.launcher.stores.xbox
+
+import org.json.JSONArray
+import org.json.JSONObject
+
+/** Request shapes follow Microsoft's web player as Greenlight (MIT) implements them. */
+internal class XboxCloudSignalling(
+ private val session: XboxCloudSession.Session,
+ private val titleId: String,
+ private val settings: XboxCloudSettings,
+) {
+ @Volatile var path: String? = null
+ private set
+
+ fun op(op: String, arg: String = ""): XboxCloudSession.Reply = when (op) {
+ "start" -> start()
+ "state" -> XboxCloudSession.request(session, "GET", "/$path/state")
+ "connect" -> connect()
+ "sdp" -> sdp(arg)
+ "ice" -> ice(arg)
+ "keepalive" -> XboxCloudSession.request(session, "POST", "/$path/keepalive", "{}")
+ else -> XboxCloudSession.Reply(0, "unknown request")
+ }
+
+ fun start(): XboxCloudSession.Reply {
+ val body = JSONObject().put("clientSessionId", "").put("titleId", titleId).put("systemUpdateGroup", "")
+ .put("settings", JSONObject().put("nanoVersion", "V3;WebrtcTransport.dll").put("enableOptionalDataCollection", false)
+ .put("enableTextToSpeech", false).put("highContrast", 0).put("locale", settings.language.ifEmpty { java.util.Locale.getDefault().toLanguageTag() })
+ .put("useIceConnection", false).put("timezoneOffsetMinutes", java.util.TimeZone.getDefault().getOffset(System.currentTimeMillis()) / 60_000)
+ .put("sdkType", "web").put("osName", settings.osName))
+ .put("serverId", "").put("fallbackRegionNames", JSONArray())
+ return XboxCloudSession.request(session, "POST", "/v5/sessions/cloud/play", body.toString()).also { reply ->
+ if (reply.status in 200..299) path = JSONObject(reply.body).getString("sessionPath").trim('/')
+ }
+ }
+
+ fun connect(): XboxCloudSession.Reply {
+ val token = session.userToken ?: return XboxCloudSession.Reply(0, "no cloud transfer token for this account")
+ return XboxCloudSession.request(session, "POST", "/$path/connect", JSONObject().put("userToken", token).toString())
+ }
+
+ fun sdp(offer: String): XboxCloudSession.Reply {
+ val body = JSONObject().put("messageType", "offer").put("sdp", offer).put("requestId", "1").put("configuration", JSONObject()
+ .put("chatConfiguration", JSONObject().put("bytesPerSample", 2).put("expectedClipDurationMs", 20)
+ .put("format", JSONObject().put("codec", "opus").put("container", "webm")).put("numChannels", 1).put("sampleFrequencyHz", 24000))
+ .put("chat", version(1, 1)).put("control", version(1, 3)).put("input", version(1, 9)).put("message", version(1, 1))
+ .put("reliableinput", version(9, 9)).put("unreliableinput", version(9, 9)))
+ val posted = XboxCloudSession.request(session, "POST", "/$path/sdp", body.toString())
+ return if (posted.status !in 200..299) posted else poll("/$path/sdp")
+ }
+
+ fun ice(candidates: String): XboxCloudSession.Reply {
+ val posted = XboxCloudSession.request(session, "POST", "/$path/ice", JSONObject().put("candidates", JSONArray(candidates)).toString())
+ return if (posted.status !in 200..299) posted else poll("/$path/ice")
+ }
+
+ fun stop(): XboxCloudSession.Reply? {
+ val current = path ?: return null
+ path = null
+ return runCatching { XboxCloudSession.request(session, "DELETE", "/$current") }.getOrNull()
+ }
+
+ private fun version(min: Int, max: Int) = JSONObject().put("minVersion", min).put("maxVersion", max)
+
+ /** The exchange answers 204 until the console has replied. */
+ private fun poll(path: String): XboxCloudSession.Reply {
+ repeat(60) {
+ val reply = XboxCloudSession.request(session, "GET", path)
+ if (reply.status != 204) return reply
+ Thread.sleep(500)
+ }
+ return XboxCloudSession.Reply(0, "no answer from the cloud console")
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudTokenService.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudTokenService.kt
new file mode 100644
index 000000000..03df4c19d
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxCloudTokenService.kt
@@ -0,0 +1,74 @@
+package com.droiddeck.launcher.stores.xbox
+
+import android.app.Service
+import android.content.ComponentName
+import android.content.Context
+import android.content.Intent
+import android.content.ServiceConnection
+import android.os.Bundle
+import android.os.Handler
+import android.os.HandlerThread
+import android.os.IBinder
+import android.os.Looper
+import android.os.Message
+import android.os.Messenger
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+
+/**
+ * Hands the main process's Xbox Cloud Gaming tokens to the native player's process (:xcloudnative) over Binder, so
+ * they stay in memory (not exported, same app only) and a stream does not wait for a second ~20 s token fetch.
+ */
+class XboxCloudTokenService : Service() {
+ private val worker = Executors.newSingleThreadExecutor()
+ private val messenger = Messenger(Handler(Looper.getMainLooper()) { message ->
+ val replyTo = message.replyTo ?: return@Handler true
+ val account = message.data?.getString("account").orEmpty()
+ worker.execute {
+ val reply = Message.obtain(null, REPLY)
+ reply.data = runCatching { XboxCloudSession.toBundle(XboxCloudSession.get(applicationContext, account)) }
+ .getOrElse { Bundle().apply { putString("error", it.message ?: it.javaClass.simpleName) } }
+ runCatching { replyTo.send(reply) }
+ }
+ true
+ })
+
+ override fun onBind(intent: Intent?): IBinder = messenger.binder
+
+ override fun onDestroy() { worker.shutdown(); super.onDestroy() }
+
+ companion object {
+ private const val REQUEST = 1
+ private const val REPLY = 2
+
+ /** Blocks until the main process answers (its tokens, or "error"), or returns null when it cannot be reached. */
+ fun request(context: Context, account: String, timeoutSeconds: Long): Bundle? {
+ val app = context.applicationContext
+ val done = CountDownLatch(1)
+ var answer: Bundle? = null
+ val thread = HandlerThread("xcloud-token").apply { start() }
+ val replies = Messenger(Handler(thread.looper) { message ->
+ if (message.what == REPLY) { answer = message.data; done.countDown() }
+ true
+ })
+ val connection = object : ServiceConnection {
+ override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
+ val ask = Message.obtain(null, REQUEST).apply { data = Bundle().apply { putString("account", account) }; replyTo = replies }
+ if (binder == null || runCatching { Messenger(binder).send(ask) }.isFailure) done.countDown()
+ }
+ override fun onServiceDisconnected(name: ComponentName?) { done.countDown() }
+ }
+ val bound = runCatching { app.bindService(Intent(app, XboxCloudTokenService::class.java), connection, Context.BIND_AUTO_CREATE) }
+ .getOrDefault(false)
+ try {
+ if (!bound) return null
+ done.await(timeoutSeconds, TimeUnit.SECONDS)
+ return answer
+ } finally {
+ if (bound) runCatching { app.unbindService(connection) }
+ thread.quitSafely()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCache.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCache.kt
index bf9e7ee70..5de1abcac 100644
--- a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCache.kt
+++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCache.kt
@@ -23,18 +23,19 @@ internal object XboxLibraryCache {
fun clear(app: Context) { prefs(app).edit().remove("library").commit() }
internal fun encode(account: String, market: String, games: List, now: Long = System.currentTimeMillis()): String =
- JSONObject().put("schema", 1).put("account", account).put("market", market).put("savedAt", now)
+ JSONObject().put("schema", 2).put("account", account).put("market", market).put("savedAt", now)
.put("games", JSONArray().apply {
for (game in games) put(JSONObject().put("id", game.id).put("title", game.title)
.put("image", game.imageUrl).put("poster", game.tallImageUrl).put("description", game.description)
.put("developer", game.developer).put("publisher", game.publisher).put("size", game.sizeBytes)
- .put("access", if (game.subscription) "gamepass" else if (game.owned) "owned" else "none"))
+ .put("access", if (game.subscription) "gamepass" else if (game.owned) "owned" else "none")
+ .put("pc", game.xboxPc).put("cloud", game.xboxCloud).put("cloudId", game.extra["cloudId"].orEmpty()))
}).toString()
internal fun decode(text: String?, account: String, market: String, now: Long = System.currentTimeMillis()): Snapshot? = runCatching {
require(text != null && text.length <= 8_000_000)
val json = JSONObject(text)
- require(json.getInt("schema") == 1 && json.getString("account") == account && json.getString("market") == market)
+ require(json.getInt("schema") == 2 && json.getString("account") == account && json.getString("market") == market)
val savedAt = json.getLong("savedAt")
require(savedAt > 0 && savedAt <= now + 60_000)
val rows = json.getJSONArray("games")
@@ -44,12 +45,14 @@ internal object XboxLibraryCache {
val id = row.getString("id")
val title = row.getString("title")
val access = row.getString("access")
+ val cloudId = row.optString("cloudId").takeIf { it.matches(Regex("[A-Z0-9]{12}")) }.orEmpty()
require(id.matches(Regex("[A-Z0-9]{12}")) && title.isNotBlank() && access in setOf("owned", "gamepass"))
CatalogItem(Store.XBOX, id, title, row.optString("image").takeIf { it.isNotEmpty() },
tallImageUrl = row.optString("poster").takeIf { it.isNotEmpty() },
description = row.optString("description"), developer = row.optString("developer"), publisher = row.optString("publisher"),
sizeBytes = row.optLong("size"), owned = access == "owned", subscription = access == "gamepass",
- storeUrl = "https://www.xbox.com/games/store/$id", extra = mapOf("market" to market, "access" to access))
+ storeUrl = "https://www.xbox.com/games/store/$id", extra = mapOf("market" to market, "access" to access,
+ "pc" to XboxCatalog.flag(row.getBoolean("pc")), "cloud" to XboxCatalog.flag(row.getBoolean("cloud")), "cloudId" to cloudId))
}
require(games.map { it.id }.distinct().size == games.size)
Snapshot(games, savedAt)
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLibraryFilter.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLibraryFilter.kt
new file mode 100644
index 000000000..3e3e001d7
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxLibraryFilter.kt
@@ -0,0 +1,41 @@
+package com.droiddeck.launcher.stores.xbox
+
+import android.content.Context
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import com.droiddeck.launcher.stores.CatalogItem
+
+object XboxLibraryFilter {
+ const val ALL = "all"
+ const val OWNED = "owned"
+ const val GAME_PASS = "gamepass"
+ const val CLOUD = "cloud"
+ val ALL_FILTERS = listOf(ALL, OWNED, GAME_PASS, CLOUD)
+
+ var current by mutableStateOf(ALL)
+ private set
+
+ fun load(context: Context) {
+ current = context.getSharedPreferences("xbox", Context.MODE_PRIVATE).getString("libraryFilter", ALL)?.takeIf { it in ALL_FILTERS } ?: ALL
+ }
+
+ fun set(context: Context, filter: String) {
+ current = filter
+ context.getSharedPreferences("xbox", Context.MODE_PRIVATE).edit().putString("libraryFilter", filter).apply()
+ }
+
+ fun keeps(filter: String, item: CatalogItem): Boolean = when (filter) {
+ OWNED -> item.owned
+ GAME_PASS -> item.subscription
+ CLOUD -> item.xboxCloud
+ else -> true
+ }
+
+ fun label(filter: String): String = when (filter) {
+ OWNED -> "Owned"
+ GAME_PASS -> "Game Pass"
+ CLOUD -> "Cloud"
+ else -> "All"
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativePad.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativePad.kt
new file mode 100644
index 000000000..661023d54
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativePad.kt
@@ -0,0 +1,80 @@
+package com.droiddeck.launcher.stores.xbox
+
+import android.view.InputDevice
+import android.view.KeyEvent
+import android.view.MotionEvent
+
+/**
+ * Controller state for the native cloud player, from Android key and motion events (no Gamepad API). Button
+ * bits follow the xCloud input report (Greenlight's packet.ts): Nexus 2, Menu 4, View 8, A 16, B 32, X 64,
+ * Y 128, D-pad 256..2048, shoulders 4096 / 8192, stick clicks 16384 / 32768.
+ */
+internal class XboxNativePad {
+ data class State(val buttons: Int, val lx: Float, val ly: Float, val rx: Float, val ry: Float, val lt: Float, val rt: Float) {
+ /** Greenlight's rule: any button change, a stick moved past 0.08 or held past 0.15, a trigger past 0.01. */
+ fun differsFrom(o: State): Boolean = buttons != o.buttons ||
+ listOf(lx to o.lx, ly to o.ly, rx to o.rx, ry to o.ry).any { (a, b) -> Math.abs(a - b) > 0.08f || Math.abs(a) > 0.15f } ||
+ Math.abs(lt - o.lt) > 0.01f || Math.abs(rt - o.rt) > 0.01f
+ }
+
+ @Volatile private var buttons = 0
+ @Volatile private var hatButtons = 0
+ @Volatile private var lx = 0f; @Volatile private var ly = 0f
+ @Volatile private var rx = 0f; @Volatile private var ry = 0f
+ @Volatile private var lt = 0f; @Volatile private var rt = 0f
+ /** Called after any change (instant controller input). */
+ @Volatile var onChanged: (() -> Unit)? = null
+
+ /** Buttons pressed on behalf of the app (the menu's Xbox button), OR-ed in. */
+ @Volatile var overrideButtons = 0
+
+ fun snapshot() = State(buttons or hatButtons or overrideButtons, lx, ly, rx, ry, lt, rt)
+
+ /** True when the event was a controller button this pad tracks. */
+ fun onKey(event: KeyEvent): Boolean {
+ val bit = when (event.keyCode) {
+ KeyEvent.KEYCODE_BUTTON_A -> 16
+ KeyEvent.KEYCODE_BUTTON_B -> 32
+ KeyEvent.KEYCODE_BUTTON_X -> 64
+ KeyEvent.KEYCODE_BUTTON_Y -> 128
+ KeyEvent.KEYCODE_DPAD_UP -> 256
+ KeyEvent.KEYCODE_DPAD_DOWN -> 512
+ KeyEvent.KEYCODE_DPAD_LEFT -> 1024
+ KeyEvent.KEYCODE_DPAD_RIGHT -> 2048
+ KeyEvent.KEYCODE_BUTTON_L1 -> 4096
+ KeyEvent.KEYCODE_BUTTON_R1 -> 8192
+ KeyEvent.KEYCODE_BUTTON_THUMBL -> 16384
+ KeyEvent.KEYCODE_BUTTON_THUMBR -> 32768
+ KeyEvent.KEYCODE_BUTTON_START -> 4
+ KeyEvent.KEYCODE_BUTTON_SELECT -> 8
+ // Digital triggers on pads without analog axes.
+ KeyEvent.KEYCODE_BUTTON_L2 -> { lt = if (event.action == KeyEvent.ACTION_DOWN) 1f else 0f; onChanged?.invoke(); return true }
+ KeyEvent.KEYCODE_BUTTON_R2 -> { rt = if (event.action == KeyEvent.ACTION_DOWN) 1f else 0f; onChanged?.invoke(); return true }
+ else -> return false
+ }
+ buttons = if (event.action == KeyEvent.ACTION_DOWN) buttons or bit else buttons and bit.inv()
+ onChanged?.invoke()
+ return true
+ }
+
+ /** True when the event came from a joystick (sticks, triggers, hat). */
+ fun onMotion(event: MotionEvent): Boolean {
+ if (event.source and InputDevice.SOURCE_JOYSTICK != InputDevice.SOURCE_JOYSTICK || event.action != MotionEvent.ACTION_MOVE) return false
+ val device = event.device
+ fun axis(code: Int): Float {
+ val value = event.getAxisValue(code)
+ val flat = device?.getMotionRange(code, event.source)?.flat ?: 0f
+ return if (Math.abs(value) > maxOf(flat, 0.08f)) value else 0f
+ }
+ lx = axis(MotionEvent.AXIS_X); ly = axis(MotionEvent.AXIS_Y)
+ rx = axis(MotionEvent.AXIS_Z); ry = axis(MotionEvent.AXIS_RZ)
+ lt = maxOf(axis(MotionEvent.AXIS_LTRIGGER), axis(MotionEvent.AXIS_BRAKE))
+ rt = maxOf(axis(MotionEvent.AXIS_RTRIGGER), axis(MotionEvent.AXIS_GAS))
+ val hx = event.getAxisValue(MotionEvent.AXIS_HAT_X); val hy = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
+ hatButtons = (if (hy < -0.5f) 256 else 0) or (if (hy > 0.5f) 512 else 0) or (if (hx < -0.5f) 1024 else 0) or (if (hx > 0.5f) 2048 else 0)
+ onChanged?.invoke()
+ return true
+ }
+
+ fun reset() { buttons = 0; hatButtons = 0; overrideButtons = 0; lx = 0f; ly = 0f; rx = 0f; ry = 0f; lt = 0f; rt = 0f }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativePlayerActivity.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativePlayerActivity.kt
new file mode 100644
index 000000000..97fd03a3d
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativePlayerActivity.kt
@@ -0,0 +1,852 @@
+package com.droiddeck.launcher.stores.xbox
+
+import android.content.Context
+import android.content.Intent
+import android.graphics.Color
+import android.os.Build
+import android.os.Bundle
+import android.os.VibrationEffect
+import android.view.Gravity
+import android.view.KeyEvent
+import android.view.MotionEvent
+import android.view.SurfaceHolder
+import android.view.SurfaceView
+import android.view.WindowManager
+import android.widget.FrameLayout
+import android.widget.TextView
+import androidx.activity.ComponentActivity
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.unit.dp
+import androidx.core.view.WindowCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.core.view.WindowInsetsControllerCompat
+import org.json.JSONArray
+import org.json.JSONObject
+import org.webrtc.DataChannel
+import org.webrtc.DefaultVideoEncoderFactory
+import org.webrtc.EglBase
+import org.webrtc.IceCandidate
+import org.webrtc.MediaConstraints
+import org.webrtc.MediaStream
+import org.webrtc.MediaStreamTrack
+import org.webrtc.PeerConnection
+import org.webrtc.PeerConnectionFactory
+import org.webrtc.RtpReceiver
+import org.webrtc.RtpTransceiver
+import org.webrtc.SdpObserver
+import org.webrtc.SessionDescription
+import org.webrtc.VideoTrack
+import org.webrtc.audio.JavaAudioDeviceModule
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+
+/**
+ * Native Xbox Cloud Gaming player (opt-in, Android 10+): Google's WebRTC library instead of the web page, the
+ * xCloud channels ported from Greenlight (XboxNativeProtocol), controller from Android input events, and the
+ * picture through DroidDeck's own compositor (XboxNativeVideo) so DDFG or LSFG can generate frames. Runs in
+ * its own process (:xcloudnative): that compositor instance is never the Linux session's.
+ * Sign-in and session requests are the web player's (XboxCloudSession, XboxCloudSignalling).
+ */
+class XboxNativePlayerActivity : ComponentActivity() {
+ private lateinit var root: FrameLayout
+ private lateinit var surfaceView: SurfaceView
+ private lateinit var status: TextView
+ private lateinit var overlay: TextView
+ private val worker = Executors.newSingleThreadExecutor()
+ private val ticker = Executors.newSingleThreadScheduledExecutor()
+ private val pad = XboxNativePad()
+ private var eglBase: EglBase? = null
+ private var factory: PeerConnectionFactory? = null
+ private var pc: PeerConnection? = null
+ private var protocol: XboxNativeProtocol? = null
+ private var video: XboxNativeVideo? = null
+ @Volatile private var signalling: XboxCloudSignalling? = null
+ @Volatile private var settings = XboxCloudSettings()
+ @Volatile private var closed = false
+ /** The stream reached the screen at least once (a later failure is a lost connection, not a failed start). */
+ @Volatile private var connectedOnce = false
+ /** The session was ended because the player stayed in the background (Android freezes the app soon after). */
+ @Volatile private var endedInBackground = false
+ private var backgroundEnd: Runnable? = null
+ private var notice: androidx.compose.ui.platform.ComposeView? = null
+ @Volatile private var regionNames = emptyList()
+ @Volatile private var serverName: String? = null
+ private val candidates = mutableListOf()
+ private val gathered = CountDownLatch(1)
+ private var menu: androidx.compose.ui.platform.ComposeView? = null
+ private var menuPage by androidx.compose.runtime.mutableStateOf("")
+ private var lastMainEntry = 0
+ private val lastRow = mutableMapOf()
+ /** The settings this stream started with (stream-start settings changed since then need a restart). */
+ private var startedWith: XboxCloudSettings? = null
+ /**
+ * The picture's route right now: true = zero-copy (decoder straight onto the screen), false = WebRTC's texture
+ * through the Vulkan compositor. Zero-copy whenever it is chosen and frame generation is off; switched live.
+ */
+ @Volatile private var zeroCopy = false
+ /** The SurfaceView's surface while it exists (UI thread). */
+ private var screen: android.view.Surface? = null
+ private var screenW = 0
+ private var screenH = 0
+ @Volatile private var zeroCopyFrames = 0L
+ @Volatile private var decoders: org.webrtc.DroidDeckLowLatencyDecoderFactory? = null
+ private var lastZeroCopyFrames = 0L
+ private var lastSubmitted = 0L
+ /** The release of the Guide press that opened the menu must not close it again. */
+ private var swallowGuideUp = false
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
+ hideBars()
+ watchBars()
+ root = FrameLayout(this).apply { setBackgroundColor(Color.BLACK) }
+ surfaceView = SurfaceView(this)
+ root.addView(surfaceView, FrameLayout.LayoutParams(-1, -1))
+ status = TextView(this).apply { setTextColor(Color.WHITE); textSize = 16f; gravity = Gravity.CENTER }
+ root.addView(status, FrameLayout.LayoutParams(-1, -2, Gravity.BOTTOM).apply { bottomMargin = 120 })
+ overlay = TextView(this).apply {
+ setTextColor(Color.WHITE); textSize = 12f; typeface = android.graphics.Typeface.MONOSPACE
+ setBackgroundColor(Color.argb(140, 0, 0, 0)); setPadding(12, 6, 12, 6); visibility = android.view.View.GONE
+ }
+ root.addView(overlay, FrameLayout.LayoutParams(-2, -2, Gravity.TOP or Gravity.END).apply { topMargin = 16; rightMargin = 16 })
+ setContentView(root)
+ settings = XboxCloudSettings.load(this)
+ startedWith = settings
+ applyDisplayRate(settings.fpsTarget)
+ applyOverlay()
+
+ val egl = EglBase.create().also { eglBase = it }
+ val nativeVideo = XboxNativeVideo(this, egl) { log(it) }.also { video = it }
+ surfaceView.holder.addCallback(object : SurfaceHolder.Callback {
+ override fun surfaceCreated(holder: SurfaceHolder) {}
+ override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {
+ screen = holder.surface; screenW = width; screenH = height
+ // Zero-copy: the decoder owns this surface (back on it after a stand-in); the Vulkan compositor must not connect to it.
+ if (zeroCopy) { decoders?.setZeroCopySurface(holder.surface); return }
+ @Suppress("DEPRECATION")
+ nativeVideo.setSurface(holder.surface, width, height, windowManager.defaultDisplay.refreshRate)
+ }
+ override fun surfaceDestroyed(holder: SurfaceHolder) {
+ screen = null
+ if (zeroCopy) decoders?.setZeroCopySurface(null) else nativeVideo.setSurface(null, 0, 0, 60f)
+ }
+ })
+ show("Signing in to Xbox Cloud Gamingโฆ")
+ worker.execute { runCatching { run() }.onFailure { fail("start", it.message ?: it.javaClass.simpleName) } }
+ }
+
+ private fun log(text: String) = com.droiddeck.launcher.stores.StoresState.logLine("Xbox: native player: ${text.take(240)}")
+ private fun show(text: String) = runOnUiThread { status.text = text; status.visibility = if (text.isEmpty()) android.view.View.GONE else android.view.View.VISIBLE }
+
+ private fun fail(stage: String, detail: String) {
+ log("failed at $stage: $detail")
+ if (closed) return
+ if (connectedOnce) showNotice("The connection to the cloud console was lost",
+ "The stream stopped reaching this device ($detail). Restart the stream to play again: the game starts over, " +
+ "unsaved progress is lost.", webPlayer = false)
+ else showNotice("The native stream could not start ($stage)",
+ "$detail\n\nYou can try again, or use the web player instead (Stores โบ Xbox โบ Cloud settings โบ Player engine).",
+ webPlayer = true)
+ }
+
+ /** One popup at a time, in the app's dialog style; A or Enter picks the selected button, Back closes the player. */
+ private fun showNotice(title: String, text: String, webPlayer: Boolean) = runOnUiThread {
+ if (notice != null || isFinishing || isDestroyed) return@runOnUiThread
+ closeMenu()
+ show("")
+ // The stream is over: stale numbers must not stay on screen.
+ overlayOn = false
+ overlay.visibility = android.view.View.GONE
+ val view = androidx.compose.ui.platform.ComposeView(this)
+ view.setContent {
+ com.droiddeck.launcher.ui.DroidDeckTheme {
+ val first = androidx.compose.runtime.remember { androidx.compose.ui.focus.FocusRequester() }
+ androidx.compose.runtime.LaunchedEffect(Unit) { androidx.compose.runtime.withFrameNanos { }; runCatching { first.requestFocus() } }
+ @androidx.compose.runtime.Composable
+ fun NoticeButton(label: String, focus: androidx.compose.ui.focus.FocusRequester?, onClick: () -> Unit) =
+ androidx.compose.material3.TextButton(onClick = onClick,
+ modifier = androidx.compose.ui.Modifier.let { if (focus != null) it.focusRequester(focus) else it }
+ .onKeyEvent { e -> if (e.type == KeyEventType.KeyDown && e.key == Key.ButtonA) { onClick(); true } else false }) {
+ androidx.compose.material3.Text(label)
+ }
+ androidx.compose.material3.AlertDialog(
+ onDismissRequest = { finish() },
+ properties = androidx.compose.ui.window.DialogProperties(dismissOnClickOutside = false),
+ title = { androidx.compose.material3.Text(title) },
+ text = { androidx.compose.material3.Text(text) },
+ confirmButton = {
+ androidx.compose.foundation.layout.Row {
+ NoticeButton(if (connectedOnce) "Restart the stream" else "Try again", first) { restartStream() }
+ if (webPlayer) NoticeButton("Open the web player", null) {
+ startActivity(Intent(this@XboxNativePlayerActivity, XboxCloudPlayerActivity::class.java).putExtras(intent)); finish()
+ }
+ }
+ },
+ dismissButton = { NoticeButton("Close", null) { finish() } },
+ )
+ }
+ }
+ notice = view
+ root.addView(view, FrameLayout.LayoutParams(-1, -1))
+ }
+
+ // ---- background ----
+
+ override fun onStop() {
+ super.onStop()
+ if (closed || isChangingConfigurations || signalling == null || endedInBackground) return
+ // Android freezes a background app about a minute after it leaves the screen; the connection then dies
+ // without a goodbye. End the session cleanly before that and offer a restart on return.
+ log("in the background")
+ val end = Runnable { backgroundEnd = null; endInBackground() }
+ backgroundEnd = end
+ root.postDelayed(end, BACKGROUND_GRACE_MS)
+ }
+
+ override fun onStart() {
+ super.onStart()
+ backgroundEnd?.let { root.removeCallbacks(it); log("back before the stream was ended") }
+ backgroundEnd = null
+ if (endedInBackground && notice == null) showNotice("The stream ended while DroidDeck was in the background",
+ "Cloud sessions are closed after ${BACKGROUND_GRACE_MS / 1000} s away, before Android pauses the app. Restart the " +
+ "stream to play again: the game starts over, unsaved progress is lost.", webPlayer = false)
+ }
+
+ private fun endInBackground() {
+ if (closed || endedInBackground) return
+ endedInBackground = true
+ closed = true
+ log("ending the session: ${BACKGROUND_GRACE_MS / 1000} s in the background")
+ val signals = signalling
+ signalling = null
+ ticker.shutdownNow()
+ runOnUiThread { overlayOn = false; overlay.visibility = android.view.View.GONE }
+ Thread({ signals?.stop()?.let { log("session closed: ${it.status}") } }, "xcloud-native-stop").start()
+ runCatching { protocol?.close() }
+ runCatching { pc?.close() }
+ }
+
+ // ---- session ----
+
+ private fun run() {
+ val account = com.droiddeck.launcher.stores.StoreAccounts.signedInAs(applicationContext, com.droiddeck.launcher.stores.Store.XBOX)
+ ?: error("Sign in to the Xbox store first.")
+ val requested = intent.getStringExtra("product").orEmpty()
+ val cached = XboxLibraryCache.read(applicationContext, account, Xodus.market())?.games?.firstOrNull { it.id == requested }
+ val product = cached?.extra?.get("cloudId")?.takeIf { it.isNotEmpty() } ?: requested
+ // The main process's tokens when it has them (no second ~20 s fetch); else this process fetches its own.
+ val shared = runCatching { XboxCloudSession.fromMainProcess(applicationContext, account) }
+ .onFailure { log("tokens from the main process: ${it.message?.take(160)}") }.getOrNull()
+ log(if (shared != null) "tokens from the main process" else "tokens fetched here")
+ val active = (shared ?: XboxCloudSession.get(applicationContext, account)).forStream(settings.region, settings.osName)
+ regionNames = active.regionList.map { it.name }
+ serverName = active.regionList.firstOrNull { it.baseUri == active.baseUri }?.name
+ val title = XboxCloudSession.titles(active)[product] ?: error("This game can't be streamed with this account here.")
+ val signals = XboxCloudSignalling(active, title, settings).also { signalling = it }
+ log("$product -> $title, region ${java.net.URI(active.baseUri).host}, frame gen ${settings.frameGen}")
+
+ show("Starting the game on Xbox Cloud Gamingโฆ")
+ expect("start", signals.start())
+ var connected = false
+ var waited = 0
+ while (!closed) {
+ val state = JSONObject(expect("state", signals.op("state")).body.ifEmpty { "{}" })
+ when (state.optString("state")) {
+ "Provisioned" -> break
+ "Failed" -> error("provision: ${state.optJSONObject("errorDetails")}")
+ "ReadyToConnect" -> if (!connected) { connected = true; show("Signing in to the cloud consoleโฆ"); expect("connect", signals.connect()) }
+ "WaitingForResources" -> show("In the queue for a cloud consoleโฆ ${waited / 1000} s")
+ else -> show("Getting a cloud console readyโฆ ${waited / 1000} s")
+ }
+ Thread.sleep(1000); waited += 1000
+ }
+ if (closed) return
+
+ show("Connectingโฆ")
+ // Small network buffer: no playout delay, frames go to the decoder and screen as soon as they are complete.
+ val trials = if (settings.smallBuffer) "WebRTC-ForcePlayoutDelay/min_ms:0,max_ms:0/WebRTC-ZeroPlayoutDelay/min_pacing:0ms,max_decode_queue_size:8/" else ""
+ PeerConnectionFactory.initialize(PeerConnectionFactory.InitializationOptions.builder(applicationContext)
+ .setFieldTrials(trials).createInitializationOptions())
+ val egl = eglBase!!
+ val zeroSurface = if (zeroCopyWanted(settings)) {
+ repeat(40) { if (!surfaceView.holder.surface.isValid) Thread.sleep(50) }
+ surfaceView.holder.surface.takeIf { it.isValid }
+ } else null
+ if (zeroSurface != null) {
+ // The compositor may already hold the screen; it must let go before the decoder takes it.
+ val ready = CountDownLatch(1)
+ runOnUiThread { zeroCopy = true; video?.setSurface(null, 0, 0, 60f); ready.countDown() }
+ ready.await(2, TimeUnit.SECONDS)
+ }
+ log("latency: instant input ${settings.instantInput}, small buffer ${settings.smallBuffer}, fast decoder ${settings.fastDecoder}, " +
+ "zero-copy ${zeroSurface != null}")
+ val f = PeerConnectionFactory.builder()
+ .setAudioDeviceModule(JavaAudioDeviceModule.builder(applicationContext).createAudioDeviceModule())
+ .setVideoDecoderFactory(org.webrtc.DroidDeckLowLatencyDecoderFactory(egl.eglBaseContext, settings.fastDecoder, zeroSurface) { zeroCopyFrames++ }
+ .also { decoders = it })
+ .setVideoEncoderFactory(DefaultVideoEncoderFactory(egl.eglBaseContext, true, true))
+ .createPeerConnectionFactory().also { factory = it }
+ val config = PeerConnection.RTCConfiguration(emptyList()).apply { sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN }
+ val peer = f.createPeerConnection(config, observer) ?: error("WebRTC unavailable")
+ pc = peer
+ peer.addTransceiver(MediaStreamTrack.MediaType.MEDIA_TYPE_AUDIO, RtpTransceiver.RtpTransceiverInit(RtpTransceiver.RtpTransceiverDirection.SEND_RECV))
+ peer.addTransceiver(MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO, RtpTransceiver.RtpTransceiverInit(RtpTransceiver.RtpTransceiverDirection.RECV_ONLY))
+ protocol = XboxNativeProtocol(peer, pad, { log(it) }, ::rumble) { runOnUiThread { finish() } }
+ pad.onChanged = { if (settings.instantInput) protocol?.inputChanged() }
+
+ val offer = await { done -> peer.createOffer(sdpObserver(done), MediaConstraints()) }
+ val local = SessionDescription(offer.type, tweakOffer(offer.description))
+ await { done -> peer.setLocalDescription(sdpObserver(done), local) }
+ val sdpReply = expect("sdp", signals.sdp(local.description))
+ val answer = JSONObject(JSONObject(sdpReply.body).getString("exchangeResponse"))
+ if (answer.has("status") && answer.optString("status") != "success") error("sdp answer: $answer")
+ await { done -> peer.setRemoteDescription(sdpObserver(done), SessionDescription(SessionDescription.Type.ANSWER, answer.getString("sdp"))) }
+
+ gathered.await(3, TimeUnit.SECONDS)
+ val local2 = synchronized(candidates) { candidates.toList() }.map { c ->
+ JSONObject().put("candidate", c.sdp).put("sdpMid", c.sdpMid).put("sdpMLineIndex", c.sdpMLineIndex).put("usernameFragment", JSONObject.NULL).toString()
+ }
+ val iceReply = expect("ice", signals.ice(JSONArray(local2).toString()))
+ val remote = JSONArray(JSONObject(iceReply.body).getString("exchangeResponse"))
+ for (i in 0 until remote.length()) addRemoteCandidate(peer, remote.getJSONObject(i))
+ log("signalling done: ${local2.size} local / ${remote.length()} remote candidates")
+ ticker.scheduleWithFixedDelay({ runCatching { signals.op("keepalive") } }, 30, 30, TimeUnit.SECONDS)
+ ticker.scheduleWithFixedDelay({ runCatching { updateOverlay() } }, 1, 1, TimeUnit.SECONDS)
+ }
+
+ private fun expect(stage: String, reply: XboxCloudSession.Reply): XboxCloudSession.Reply {
+ if (reply.status !in 200..299) error("$stage: ${reply.status} ${reply.body.take(200)}")
+ return reply
+ }
+
+ /** Opus stereo (Greenlight), H.264 profile order (Main first unless the user picked one) and the bitrate cap. */
+ private fun tweakOffer(sdp: String): String {
+ var out = sdp.replace("useinbandfec=1", "useinbandfec=1; stereo=1")
+ val prefix = when (settings.codec) { "low" -> "420"; "normal" -> "42e"; else -> "4d" }
+ val ids = Regex("a=fmtp:(\\d+).*profile-level-id=([0-9a-f]{6})").findAll(out).filter { it.groupValues[2].startsWith(prefix) }.map { it.groupValues[1] }.toList()
+ if (ids.isNotEmpty()) out = out.split("\r\n").joinToString("\r\n") { line ->
+ if (!line.startsWith("m=video")) line else {
+ val parts = line.trim().split(" ")
+ (parts.take(3) + ids + parts.drop(3).filter { it !in ids }).joinToString(" ")
+ }
+ }
+ if (settings.bitrateMbps > 0) {
+ val lines = out.split("\r\n").toMutableList()
+ val start = lines.indexOfFirst { it.startsWith("m=video") }
+ if (start >= 0) {
+ var i = start + 1
+ while (i < lines.size && (lines[i].startsWith("i=") || lines[i].startsWith("c="))) i++
+ if (i < lines.size && lines[i].startsWith("b=AS:")) lines[i] = "b=AS:${settings.bitrateMbps * 1000}" else lines.add(i, "b=AS:${settings.bitrateMbps * 1000}")
+ out = lines.joinToString("\r\n")
+ }
+ }
+ return out
+ }
+
+ /** The server's candidates, plus the IPv4 behind a Teredo address (Greenlight's ice.ts / teredo.ts). */
+ private fun addRemoteCandidate(peer: PeerConnection, c: JSONObject) {
+ val line = c.optString("candidate").removePrefix("a=")
+ if (line.isEmpty() || line == "end-of-candidates") return
+ val mid = c.optString("sdpMid", "0"); val index = c.optString("sdpMLineIndex", "0").toIntOrNull() ?: 0
+ val address = line.split(" ").getOrNull(4).orEmpty()
+ if (address.startsWith("2001")) runCatching {
+ val p = address.split(":")
+ val hex = p[6].padStart(4, '0') + p[7].padStart(4, '0')
+ val ip = (0 until 4).joinToString(".") { k -> ((hex.substring(k * 2, k * 2 + 2).toInt(16)).inv() and 0xFF).toString() }
+ val port = p[5].toInt(16).inv() and 0xFFFF
+ peer.addIceCandidate(IceCandidate(mid, index, "candidate:10 1 UDP 1 $ip 9002 typ host "))
+ peer.addIceCandidate(IceCandidate(mid, index, "candidate:11 1 UDP 1 $ip $port typ host "))
+ }
+ peer.addIceCandidate(IceCandidate(mid, index, line))
+ }
+
+ private val observer = object : PeerConnection.Observer {
+ override fun onSignalingChange(state: PeerConnection.SignalingState) {}
+ override fun onIceConnectionChange(state: PeerConnection.IceConnectionState) { log("ice $state") }
+ override fun onIceConnectionReceivingChange(receiving: Boolean) {}
+ override fun onIceGatheringChange(state: PeerConnection.IceGatheringState) { if (state == PeerConnection.IceGatheringState.COMPLETE) gathered.countDown() }
+ override fun onIceCandidate(candidate: IceCandidate) { synchronized(candidates) { candidates.add(candidate) } }
+ override fun onIceCandidatesRemoved(removed: Array) {}
+ override fun onAddStream(stream: MediaStream) {}
+ override fun onRemoveStream(stream: MediaStream) {}
+ override fun onDataChannel(channel: DataChannel) {}
+ override fun onRenegotiationNeeded() {}
+ override fun onAddTrack(receiver: RtpReceiver, streams: Array) { attach(receiver) }
+ override fun onTrack(transceiver: RtpTransceiver) { attach(transceiver.receiver) }
+ override fun onConnectionChange(state: PeerConnection.PeerConnectionState) {
+ log("connection $state")
+ when (state) {
+ PeerConnection.PeerConnectionState.CONNECTED -> {
+ connectedOnce = true
+ show("")
+ video?.setFpsTarget(settings.fpsTarget)
+ video?.setFrameGeneration(effectiveFrameGen())
+ }
+ PeerConnection.PeerConnectionState.FAILED -> fail("webrtc", "connection failed")
+ PeerConnection.PeerConnectionState.DISCONNECTED -> show("Connection lostโฆ")
+ else -> {}
+ }
+ }
+ }
+
+ private var videoAttached = false
+
+ private fun attach(receiver: RtpReceiver) = synchronized(this) {
+ val track = receiver.track()
+ // Always attached: in zero-copy WebRTC delivers no frames, and after a live switch back they flow again.
+ if (track is VideoTrack && !videoAttached) { videoAttached = true; video?.let { track.addSink(it) }; log("video track") }
+ }
+
+ /** LSFG needs the Lossless.dll and its shader cache built; otherwise DDFG. */
+ private fun effectiveFrameGen(): String {
+ if (!XboxNativeVideo.isLsfg(settings.frameGen)) return settings.frameGen
+ val status = com.droiddeck.launcher.gpu.Lossless.sync(applicationContext)
+ if (status == 0) return settings.frameGen
+ log("LSFG unavailable (status $status), using DDFG")
+ return XboxNativeVideo.FG_DDFG
+ }
+
+ private fun await(start: (done: (Result) -> Unit) -> Unit): T {
+ val latch = CountDownLatch(1)
+ var result: Result? = null
+ start { r -> result = r; latch.countDown() }
+ check(latch.await(15, TimeUnit.SECONDS)) { "WebRTC timed out" }
+ return result!!.getOrThrow()
+ }
+
+ @Suppress("UNCHECKED_CAST")
+ private fun sdpObserver(done: (Result) -> Unit) = object : SdpObserver {
+ override fun onCreateSuccess(description: SessionDescription) = done(Result.success(description as T))
+ override fun onSetSuccess() = done(Result.success(Unit as T))
+ override fun onCreateFailure(error: String) = done(Result.failure(IllegalStateException(error)))
+ override fun onSetFailure(error: String) = done(Result.failure(IllegalStateException(error)))
+ }
+
+ // ---- input, rumble, overlay ----
+
+ private var guideDown = false
+ private var guideDownAt = 0L
+ private var lastGuideUp = 0L
+ private var pendingNexus: Runnable? = null
+ private fun isGuide(event: KeyEvent) = event.keyCode == KeyEvent.KEYCODE_BUTTON_MODE
+
+ private fun pressNexus() {
+ pad.overrideButtons = pad.overrideButtons or 2
+ root.postDelayed({ pad.overrideButtons = pad.overrideButtons and 2.inv() }, 200)
+ }
+
+ override fun dispatchKeyEvent(event: KeyEvent): Boolean {
+ if (menu != null) {
+ // Guide closes the menu; Back goes up one page, then closes it.
+ if (isGuide(event)) { if (event.action == KeyEvent.ACTION_UP) { if (swallowGuideUp) swallowGuideUp = false else closeMenu() }; return true }
+ if (event.keyCode == KeyEvent.KEYCODE_BACK) {
+ if (event.action == KeyEvent.ACTION_UP) { if (menuPage.isNotEmpty()) menuPage = "" else closeMenu() }
+ return true
+ }
+ return super.dispatchKeyEvent(event)
+ }
+ // Guide / the Input Controls menu button: once = the Xbox button, twice or held = the player menu.
+ if (isGuide(event)) {
+ if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
+ val pending = pendingNexus
+ if (pending != null && event.eventTime - lastGuideUp <= 350) { root.removeCallbacks(pending); pendingNexus = null; swallowGuideUp = true; openMenu() }
+ else { guideDown = true; guideDownAt = event.eventTime }
+ } else if (event.action == KeyEvent.ACTION_DOWN && guideDown && event.eventTime - guideDownAt >= 600) {
+ guideDown = false; swallowGuideUp = true; openMenu()
+ } else if (event.action == KeyEvent.ACTION_UP && guideDown) {
+ guideDown = false; lastGuideUp = event.eventTime
+ val tap = Runnable { pendingNexus = null; pressNexus() }
+ pendingNexus = tap; root.postDelayed(tap, 350)
+ }
+ return true
+ }
+ if (event.keyCode == KeyEvent.KEYCODE_BACK) { if (event.action == KeyEvent.ACTION_UP) openMenu(); return true }
+ if (pad.onKey(event)) return true
+ return super.dispatchKeyEvent(event)
+ }
+
+ // With the menu open, sticks and the hat go to Android unhandled, which turns them into D-pad keys for the menu.
+ override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean =
+ (menu == null && pad.onMotion(event)) || super.dispatchGenericMotionEvent(event)
+
+ private fun rumble(strong: Float, weak: Float, durationMs: Int) {
+ val scale = settings.rumble / 100f
+ val amplitude = (maxOf(strong, weak) * scale * 255).toInt().coerceIn(0, 255)
+ if (amplitude == 0 || durationMs <= 0) return
+ val vibrator = (if (Build.VERSION.SDK_INT >= 31) getSystemService(android.os.VibratorManager::class.java)?.defaultVibrator
+ else @Suppress("DEPRECATION") getSystemService(android.os.Vibrator::class.java)) ?: return
+ runCatching { vibrator.vibrate(VibrationEffect.createOneShot(durationMs.coerceAtMost(1000).toLong(), amplitude)) }
+ }
+
+ /** The overlay is remembered with the other cloud settings (on = overlay level above 0). */
+ @Volatile private var overlayOn = false
+
+ private var lastJitterDelay = 0.0
+ private var lastJitterCount = 0.0
+ private var lastDecodeTime = 0.0
+ private var lastDecoded = 0.0
+ private var lastBytes = 0.0
+ private var lastStatsMs = 0.0
+ private var lastLost = 0.0
+ private var lastReceived = 0.0
+
+ /**
+ * The overlay, at the web player's levels and layout (settings statsLevel / statsLayout): 1 fps and frame
+ * generation base -> shown; 2 + resolution, ping; 3 + bitrate, jitter buffer, decode time, packet loss; 4 + frame
+ * generation's added delay, the estimated picture delay (one-way network + buffer + decode + frame generation +
+ * one screen refresh), codec and server.
+ */
+ private fun updateOverlay() {
+ val level = settings.statsLevel
+ if (!overlayOn || level <= 0) return
+ val peer = pc ?: return
+ peer.getStats { report ->
+ var size = ""; var fps = 0; var codec = ""
+ var rttMs = -1.0; var bufferMs = 0.0; var decodeMs = 0.0; var mbps = 0.0; var loss = 0.0
+ val codecs = HashMap()
+ report.statsMap.values.forEach { s -> if (s.type == "codec") codecs[s.id] = (s.members["mimeType"] as? String).orEmpty().removePrefix("video/") }
+ report.statsMap.values.forEach { s ->
+ val m = s.members
+ if (s.type == "inbound-rtp" && m["kind"] == "video") {
+ size = "${m["frameWidth"]}x${m["frameHeight"]}"
+ fps = (m["framesPerSecond"] as? Number)?.toInt() ?: 0
+ codec = codecs[m["codecId"] as? String].orEmpty()
+ val jd = (m["jitterBufferDelay"] as? Number)?.toDouble() ?: 0.0
+ val jc = (m["jitterBufferEmittedCount"] as? Number)?.toDouble() ?: 0.0
+ if (jc > lastJitterCount) bufferMs = (jd - lastJitterDelay) * 1000 / (jc - lastJitterCount)
+ lastJitterDelay = jd; lastJitterCount = jc
+ val dt = (m["totalDecodeTime"] as? Number)?.toDouble() ?: 0.0
+ val df = (m["framesDecoded"] as? Number)?.toDouble() ?: 0.0
+ if (df > lastDecoded) decodeMs = (dt - lastDecodeTime) * 1000 / (df - lastDecoded)
+ lastDecodeTime = dt; lastDecoded = df
+ val bytes = (m["bytesReceived"] as? Number)?.toDouble() ?: 0.0
+ val now = s.timestampUs / 1000.0
+ if (lastStatsMs > 0 && now > lastStatsMs) mbps = (bytes - lastBytes) * 8 / ((now - lastStatsMs) * 1000)
+ lastBytes = bytes; lastStatsMs = now
+ val lost = (m["packetsLost"] as? Number)?.toDouble() ?: 0.0
+ val received = (m["packetsReceived"] as? Number)?.toDouble() ?: 0.0
+ val dl = lost - lastLost; val dr = received - lastReceived
+ if (dl + dr > 0) loss = dl * 100 / (dl + dr)
+ lastLost = lost; lastReceived = received
+ } else if (s.type == "candidate-pair" && m["state"] == "succeeded" && m["nominated"] == true) {
+ (m["currentRoundTripTime"] as? Number)?.toDouble()?.let { rttMs = it * 1000 }
+ }
+ }
+ // Zero-copy: WebRTC never sees a decoded frame, so size and decode time come from the decoder itself.
+ if (zeroCopy) decoders?.let { d ->
+ d.videoSize()?.let { size = it }
+ val ms = d.takeDecodeMs()
+ if (ms >= 0) decodeMs = ms
+ }
+ val fg = video?.stats()
+ val fgOn = fg != null && settings.frameGen != XboxNativeVideo.FG_OFF && fg[2] > 1f
+ val fgWaitMs = if (fgOn) 1000.0 / fg!![2] else 0.0
+ val fgGpuMs = if (fgOn && fg!![5] > 0f) fg[5].toDouble() else 0.0
+ @Suppress("DEPRECATION") val refreshMs = 1000.0 / windowManager.defaultDisplay.refreshRate
+ val total = (if (rttMs >= 0) rttMs / 2 else 0.0) + bufferMs + decodeMs + fgWaitMs + fgGpuMs + refreshMs
+ // What reaches the screen: the decoder's own count in zero-copy, the presented rate with frame generation,
+ // else the frames handed to the compositor (a frame-rate target drops some of WebRTC's decoded frames).
+ val submitted = video?.frames ?: 0L
+ val handed = (submitted - lastSubmitted).toInt().also { lastSubmitted = submitted }
+ val shownFps = when {
+ zeroCopy -> (zeroCopyFrames - lastZeroCopyFrames).toInt().also { lastZeroCopyFrames = zeroCopyFrames }
+ fgOn -> fg!![3].toInt()
+ else -> handed
+ }
+ val items = buildList {
+ add("native ยท " + if (settings.frameGen == XboxNativeVideo.FG_OFF) "no frame gen" else XboxNativeVideo.modeName(settings.frameGen))
+ add("$shownFps fps" + if (zeroCopy) " zero-copy" else "")
+ if (fgOn) add("${settings.frameGen} ${fg!![2].toInt()} โ ${fg[3].toInt()}")
+ if (level >= 2) { add(size); add(if (rttMs >= 0) "ping ${rttMs.toInt()} ms" else "ping โ") }
+ if (level >= 3) {
+ add("${"%.1f".format(mbps)} Mb/s"); add("buffer ${"%.1f".format(bufferMs)} ms")
+ add("decode ${"%.1f".format(decodeMs)} ms"); add("loss ${"%.1f".format(loss)}%")
+ }
+ if (level >= 4) {
+ if (fgOn) add("frame gen +${"%.1f".format(fgWaitMs + fgGpuMs)} ms (gpu ${"%.1f".format(fgGpuMs)})")
+ add("picture delay โ ${total.toInt()} ms (est.)")
+ if (codec.isNotEmpty()) add(codec)
+ serverName?.let { add(it) }
+ }
+ }
+ val text = items.joinToString(if (settings.statsLayout == "vertical") "\n" else " ยท ")
+ runOnUiThread { overlay.text = text }
+ }
+ }
+
+ // ---- menu ----
+
+ /** The player menu (as the web player's): Resume, Xbox menu, Frame generation, Stream quality, overlay, Leave. */
+ private fun openMenu() {
+ if (menu != null) return
+ pad.reset()
+ menuPage = ""
+ val regions = regionNames
+ val lsfg = XboxNativeVideo.lsfgReady(this)
+ val view = androidx.compose.ui.platform.ComposeView(this)
+ view.setContent {
+ com.droiddeck.launcher.ui.DroidDeckTheme {
+ var current by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(settings) }
+ val entries = androidx.compose.runtime.remember { List(7) { androidx.compose.ui.focus.FocusRequester() } }
+ val choices = androidx.compose.runtime.remember { List(5) { androidx.compose.ui.focus.FocusRequester() } }
+ androidx.compose.runtime.LaunchedEffect(menuPage) {
+ // Wait a frame so the entries are attached before one of them takes the selection.
+ androidx.compose.runtime.withFrameNanos { }
+ runCatching {
+ if (menuPage.isEmpty()) entries[lastMainEntry.coerceIn(0, 6)].requestFocus()
+ else if (menuPage == "framegen") choices[FG_MODES.indexOf(current.frameGen).coerceIn(0, if (lsfg) 4 else 1)].requestFocus()
+ }
+ }
+ val cs = androidx.compose.material3.MaterialTheme.colorScheme
+ // A full-size box first: the root of a ComposeView gets fixed full-screen constraints, so the panel's
+ // width only holds inside it (as the web player's menu does).
+ androidx.compose.foundation.layout.Box(androidx.compose.ui.Modifier.fillMaxSize()) {
+ androidx.compose.foundation.layout.Column(
+ androidx.compose.ui.Modifier.fillMaxHeight().width(400.dp).background(cs.surface.copy(alpha = 0.95f))
+ .verticalScroll(androidx.compose.foundation.rememberScrollState()).padding(vertical = 12.dp),
+ ) {
+ androidx.compose.material3.Text("${intent.getStringExtra("title").orEmpty()} ยท native",
+ style = androidx.compose.material3.MaterialTheme.typography.titleMedium, color = cs.onSurface,
+ modifier = androidx.compose.ui.Modifier.padding(horizontal = 16.dp, vertical = 4.dp))
+ fun fgName(mode: String) = when (mode) {
+ XboxNativeVideo.FG_DDFG -> "DDFG 2ร"; XboxNativeVideo.FG_LSFG -> "LSFG 2ร"
+ XboxNativeVideo.FG_LSFG3 -> "LSFG 3ร"; XboxNativeVideo.FG_LSFG4 -> "LSFG 4ร"; else -> "Off"
+ }
+ fun fpsName(s: XboxCloudSettings) = if (s.fpsTarget == 0) "the screen's rate" else "${s.fpsTarget} fps"
+ when (menuPage) {
+ "" -> {
+ XboxMenuEntry("Resume", "Back to the game", entries[0], { lastMainEntry = 0 }) { closeMenu() }
+ XboxMenuEntry("Xbox menu", "The game's Xbox menu (also: Guide once)", entries[1], { lastMainEntry = 1 }) { closeMenu(); pressNexus() }
+ XboxMenuEntry("Frame generation & low latency", "Now: ${fgName(current.frameGen)}" + if (zeroCopy) " ยท zero-copy" else "",
+ entries[2], { lastMainEntry = 2 }) { menuPage = "framegen" }
+ XboxMenuEntry("Frame rate", "Target: ${fpsName(current)}", entries[3], { lastMainEntry = 3 }) { menuPage = "framerate" }
+ XboxMenuEntry("Stream quality", "Engine, resolution, region, codec, bitrate (next stream)", entries[4], { lastMainEntry = 4 }) { menuPage = "stream" }
+ XboxMenuEntry("Performance overlay and rumble", if (current.statsLevel == 0) "Overlay off"
+ else "Overlay: " + listOf("", "FPS only", "Basic", "Detailed", "Full")[current.statsLevel] + ", " + current.statsLayout,
+ entries[5], { lastMainEntry = 5 }) { menuPage = "overlay-native" }
+ XboxMenuEntry("Leave", "End the stream", entries[6], { lastMainEntry = 6 }) { finish() }
+ androidx.compose.material3.Text("Guide twice opens this menu; Back or Guide closes it.",
+ style = androidx.compose.material3.MaterialTheme.typography.bodySmall, color = cs.onSurfaceVariant,
+ modifier = androidx.compose.ui.Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
+ }
+ "framegen" -> {
+ XboxMenuEntry("โน Back", null) { menuPage = "" }
+ val modes = if (lsfg) FG_MODES else FG_MODES.take(2)
+ modes.forEachIndexed { i, mode ->
+ XboxMenuEntry((if (current.frameGen == mode) "โ " else "") + fgName(mode), when (mode) {
+ XboxNativeVideo.FG_DDFG -> "DroidDeck's own frame generation (DDFG); doubles the shown frame rate"
+ XboxNativeVideo.FG_LSFG, XboxNativeVideo.FG_LSFG3, XboxNativeVideo.FG_LSFG4 ->
+ "Lossless Scaling frame generation (the Lossless.dll DroidDeck uses for games); the frame-rate target caps the output"
+ else -> "Show the stream's own frames only"
+ }, choices[i]) { current = current.copy(frameGen = mode); setFrameGen(mode) }
+ }
+ if (!lsfg) androidx.compose.material3.Text("LSFG appears here once DroidDeck has the Lossless.dll (frame generation settings).",
+ style = androidx.compose.material3.MaterialTheme.typography.bodySmall, color = cs.onSurfaceVariant,
+ modifier = androidx.compose.ui.Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
+ if (current.zeroCopy) androidx.compose.material3.Text(
+ "Zero-copy is used while frame generation is Off; picking a frame generation mode switches to the compositor live.",
+ style = androidx.compose.material3.MaterialTheme.typography.bodySmall, color = cs.onSurfaceVariant,
+ modifier = androidx.compose.ui.Modifier.padding(horizontal = 16.dp, vertical = 8.dp))
+ XboxCloudSettingsPanel(current, regionNames, live = true, section = "latency", restoreRow = null,
+ startedWith = startedWith) { changed ->
+ val before = current
+ current = changed
+ applyLive(before, changed)
+ }
+ XboxMenuEntry("Restart the stream with these settings",
+ "Ends this cloud session and starts a new one; the game restarts, unsaved progress is lost.") { restartStream() }
+ }
+ else -> {
+ XboxMenuEntry("โน Back", null) { menuPage = "" }
+ if (menuPage == "stream") XboxMenuEntry("Restart the stream with these settings",
+ "Ends this cloud session and starts a new one; the game restarts, unsaved progress is lost.") { restartStream() }
+ val page = menuPage
+ XboxCloudSettingsPanel(current, regions, live = true, section = page, restoreRow = lastRow[page] ?: 0,
+ onRowFocused = { lastRow[page] = it }, startedWith = startedWith) { changed ->
+ val before = current
+ current = changed
+ applyLive(before, changed)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ menu = view
+ root.addView(view, FrameLayout.LayoutParams(-1, -1))
+ view.requestFocus()
+ }
+
+ /**
+ * Applies a settings change from any menu page at once: frame generation, the frame-rate target, zero-copy, instant
+ * input and the overlay. Resolution, region, language, codec, bitrate, the network buffer, the decoder mode and the
+ * engine belong to the cloud session and need a restart (the page says so).
+ */
+ private fun applyLive(before: XboxCloudSettings, changed: XboxCloudSettings) {
+ settings = changed
+ XboxCloudSettings.save(this, changed)
+ if (changed.frameGen != before.frameGen) setFrameGen(changed.frameGen)
+ else if (changed.zeroCopy != before.zeroCopy) applyRoute()
+ if (changed.fpsTarget != before.fpsTarget) { applyDisplayRate(changed.fpsTarget); video?.setFpsTarget(changed.fpsTarget); applyRoute() }
+ applyOverlay()
+ }
+
+ /**
+ * Zero-copy draws every decoded frame straight onto the screen, so it is used only when nothing has to touch the
+ * frames: frame generation off and no frame-rate target below the stream's 60 fps (the compositor drops frames for that).
+ */
+ private fun zeroCopyWanted(s: XboxCloudSettings) =
+ s.zeroCopy && s.frameGen == XboxNativeVideo.FG_OFF && (s.fpsTarget == 0 || s.fpsTarget >= 60)
+
+ private fun applyOverlay() {
+ overlayOn = settings.statsLevel > 0
+ overlay.visibility = if (overlayOn) android.view.View.VISIBLE else android.view.View.GONE
+ overlay.textSize = 12f
+ // Top left keeps clear of the clock Xbox draws top right while its guide is open.
+ val left = settings.statsCorner == "left"
+ overlay.layoutParams = (overlay.layoutParams as FrameLayout.LayoutParams).apply {
+ gravity = Gravity.TOP or (if (left) Gravity.START else Gravity.END)
+ leftMargin = if (left) 16 else 0; rightMargin = if (left) 0 else 16
+ }
+ overlay.gravity = if (left) Gravity.START else Gravity.END
+ }
+
+ /** Stream-start settings need a new cloud session: end this one, then open the player again. */
+ private fun restartStream() {
+ val signals = signalling
+ closed = true
+ closeMenu()
+ notice?.let { root.removeView(it) }
+ notice = null
+ show("Restarting the streamโฆ")
+ Thread({
+ signals?.stop()?.let { log("restart: session closed ${it.status}") }
+ runOnUiThread { if (!isFinishing) { startActivity(XboxCloudPlayerActivity.playerIntent(this, intent.getStringExtra("product").orEmpty(), intent.getStringExtra("title").orEmpty())); finish() } }
+ }, "xcloud-native-restart").start()
+ }
+
+ private fun closeMenu() {
+ menu?.let { root.removeView(it) }
+ menu = null
+ pad.reset()
+ }
+
+ private fun setFrameGen(mode: String) {
+ settings = settings.copy(frameGen = mode)
+ XboxCloudSettings.save(this, settings)
+ applyRoute()
+ worker.execute { video?.setFrameGeneration(effectiveFrameGen()) }
+ }
+
+ /**
+ * Live route switch (UI thread): zero-copy when chosen and frame generation is off, else the compositor.
+ * The screen surface takes one producer at a time, so the old one lets go before the new one connects.
+ */
+ private fun applyRoute() {
+ val d = decoders ?: return
+ val want = zeroCopyWanted(settings)
+ if (want == zeroCopy) return
+ val target = screen
+ if (want) {
+ if (target == null) return
+ video?.setSurface(null, 0, 0, 60f)
+ if (d.useScreen(target)) { lastZeroCopyFrames = zeroCopyFrames; zeroCopy = true; log("route: zero-copy") }
+ else { attachCompositor(target); log("route: zero-copy refused by the decoder, compositor kept") }
+ } else {
+ if (!d.useScreen(null)) { log("route: decoder could not leave the screen, zero-copy kept"); return }
+ zeroCopy = false
+ target?.let { attachCompositor(it) }
+ log("route: compositor")
+ }
+ }
+
+ private fun attachCompositor(target: android.view.Surface) {
+ @Suppress("DEPRECATION")
+ video?.setSurface(target, screenW, screenH, windowManager.defaultDisplay.refreshRate)
+ }
+
+ /**
+ * Ask Android for the display mode whose refresh rate fits the target (at least the target when possible,
+ * else the fastest; the fastest for "the screen's rate"), so generated frames are not held back at 60 Hz.
+ */
+ private fun applyDisplayRate(target: Int) {
+ @Suppress("DEPRECATION") val display = windowManager.defaultDisplay
+ val current = display.mode
+ val modes = display.supportedModes.filter { it.physicalWidth == current.physicalWidth && it.physicalHeight == current.physicalHeight }
+ val pick = (if (target > 0) modes.filter { it.refreshRate >= target - 0.5f }.minByOrNull { it.refreshRate } else null)
+ ?: modes.maxByOrNull { it.refreshRate } ?: return
+ window.attributes = window.attributes.apply { preferredDisplayModeId = pick.modeId }
+ log("display mode ${pick.refreshRate.toInt()} Hz for target ${if (target > 0) target else "screen"}")
+ }
+
+ private val rehideBars = Runnable { if (window.decorView.hasWindowFocus()) hideBars() }
+
+ /**
+ * Bars that come back while the player has focus (return from recents or another app, a swipe) are hidden
+ * again after a moment; focus alone missed the return from the background (10-24).
+ */
+ private fun watchBars() {
+ androidx.core.view.ViewCompat.setOnApplyWindowInsetsListener(window.decorView) { v, insets ->
+ if (insets.isVisible(WindowInsetsCompat.Type.navigationBars()) || insets.isVisible(WindowInsetsCompat.Type.statusBars())) {
+ v.removeCallbacks(rehideBars)
+ v.postDelayed(rehideBars, 1500)
+ }
+ androidx.core.view.ViewCompat.onApplyWindowInsets(v, insets)
+ }
+ }
+
+ override fun onResume() {
+ super.onResume()
+ hideBars()
+ }
+
+ private fun hideBars() {
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+ WindowInsetsControllerCompat(window, window.decorView).apply {
+ hide(WindowInsetsCompat.Type.systemBars())
+ systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
+ }
+ }
+
+ override fun onWindowFocusChanged(hasFocus: Boolean) { super.onWindowFocusChanged(hasFocus); if (hasFocus) hideBars() }
+
+ override fun onDestroy() {
+ closed = true
+ ticker.shutdownNow()
+ val signals = signalling
+ Thread({ signals?.stop()?.let { log("session closed: ${it.status}") } }, "xcloud-native-stop").start()
+ runCatching { protocol?.close() }
+ runCatching { pc?.close(); pc?.dispose() }
+ runCatching { video?.release() }
+ runCatching { factory?.dispose() }
+ runCatching { decoders?.release() }
+ runCatching { eglBase?.release() }
+ worker.shutdownNow()
+ super.onDestroy()
+ }
+
+ companion object {
+ /** How long a stream may stay in the background (Android froze the app ~70 s after it left the screen, 10-24). */
+ private const val BACKGROUND_GRACE_MS = 45_000L
+ private val FG_MODES = listOf(XboxNativeVideo.FG_OFF, XboxNativeVideo.FG_DDFG, XboxNativeVideo.FG_LSFG, XboxNativeVideo.FG_LSFG3, XboxNativeVideo.FG_LSFG4)
+
+ /** True when the native engine can run here (ImageReader GPU buffers need Android 10). */
+ fun supported(): Boolean = Build.VERSION.SDK_INT >= 29
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativeProtocol.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativeProtocol.kt
new file mode 100644
index 000000000..348079f52
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativeProtocol.kt
@@ -0,0 +1,186 @@
+package com.droiddeck.launcher.stores.xbox
+
+import org.json.JSONObject
+import org.webrtc.DataChannel
+import org.webrtc.PeerConnection
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import java.nio.charset.StandardCharsets
+import java.util.UUID
+import java.util.concurrent.Executors
+import java.util.concurrent.ScheduledFuture
+import java.util.concurrent.TimeUnit
+
+/**
+ * The xCloud data-channel protocol for the native player, ported from Greenlight's player (UnknownSKL, MIT;
+ * tools/xcloud-player/greenlight/lib/channel): chat / control / input / message channels, the message
+ * handshake, authorization and gamepad announcements, and the binary input reports. The controller state
+ * comes from Android input events ([pad]); rumble reports come back through [onRumble].
+ */
+internal class XboxNativeProtocol(
+ pc: PeerConnection,
+ private val pad: XboxNativePad,
+ private val log: (String) -> Unit,
+ private val onRumble: (strong: Float, weak: Float, durationMs: Int) -> Unit,
+ private val onServerDisconnect: () -> Unit,
+) {
+ private val chat = pc.createDataChannel("chat", init("chatV1"))
+ private val control = pc.createDataChannel("control", init("controlV1"))
+ private val input = pc.createDataChannel("input", init("1.0"))
+ private val message = pc.createDataChannel("message", init("messageV1"))
+ private val timer = Executors.newSingleThreadScheduledExecutor { r -> Thread(r, "xcloud-input") }
+ private var loop: ScheduledFuture<*>? = null
+ private var sequence = 0
+ private var lastSent: XboxNativePad.State? = null
+ private var lastSentAt = 0L
+ @Volatile var handshakeDone = false
+ private set
+
+ init {
+ message.registerObserver(ChannelObserver(message, ::onMessageOpen, ::onMessageData))
+ control.registerObserver(ChannelObserver(control, {}, {}))
+ input.registerObserver(ChannelObserver(input, {}, ::onInputData))
+ chat.registerObserver(ChannelObserver(chat, {}, {}))
+ }
+
+ private fun init(protocol: String) = DataChannel.Init().apply { ordered = true; this.protocol = protocol }
+
+ private inner class ChannelObserver(
+ private val channel: DataChannel,
+ private val onOpen: () -> Unit,
+ private val onData: (DataChannel.Buffer) -> Unit,
+ ) : DataChannel.Observer {
+ private var opened = false
+ override fun onBufferedAmountChange(previous: Long) {}
+ override fun onStateChange() {
+ if (!opened && channel.state() == DataChannel.State.OPEN) { opened = true; runCatching(onOpen).onFailure { log("channel open failed: ${it.message}") } }
+ }
+ override fun onMessage(buffer: DataChannel.Buffer) { runCatching { onData(buffer) }.onFailure { log("channel message failed: ${it.message}") } }
+ }
+
+ private fun send(channel: DataChannel, text: String) {
+ if (channel.state() != DataChannel.State.OPEN) return
+ channel.send(DataChannel.Buffer(ByteBuffer.wrap(text.toByteArray(StandardCharsets.UTF_8)), false))
+ }
+
+ private fun send(channel: DataChannel, bytes: ByteBuffer) {
+ if (channel.state() != DataChannel.State.OPEN) return
+ channel.send(DataChannel.Buffer(bytes, true))
+ }
+
+ private fun onMessageOpen() {
+ log("message channel open, handshake")
+ send(message, JSONObject().put("type", "Handshake").put("version", "messageV1")
+ .put("id", "be0bfc6d-1e83-4c8a-90ed-fa8601c5a179").put("cv", "0").toString())
+ }
+
+ private fun onMessageData(buffer: DataChannel.Buffer) {
+ val bytes = ByteArray(buffer.data.remaining()).also { buffer.data.get(it) }
+ val data = JSONObject(String(bytes, StandardCharsets.UTF_8))
+ when (data.optString("type")) {
+ "HandshakeAck" -> {
+ handshakeDone = true
+ log("handshake done")
+ // Greenlight's order: authorization (announces controller 0 and removes it), input start, config.
+ send(control, JSONObject().put("message", "authorizationRequest").put("accessKey", "4BDB3609-C1F1-4195-9B37-FEFF45DA8B8E").toString())
+ gamepadChanged(0, true); gamepadChanged(0, false)
+ startInput()
+ sendConfig()
+ // Our controller, after the authorization's add/remove.
+ gamepadChanged(0, true)
+ send(control, JSONObject().put("message", "videoKeyframeRequested").put("ifrRequested", true).toString())
+ }
+ "TransactionStart", "Message" -> handleTransaction(data)
+ else -> {}
+ }
+ }
+
+ private fun handleTransaction(data: JSONObject) {
+ when (data.optString("target")) {
+ "/streaming/sessionLifetimeManagement/serverInitiatedDisconnect" -> {
+ complete(data.optString("id"), "")
+ onServerDisconnect()
+ }
+ // The game's own dialogs: accept (Greenlight shows a confirm; the native player has no page).
+ "/streaming/systemUi/messages/ShowMessageDialog" -> complete(data.optString("id"), JSONObject().put("Result", 0))
+ else -> {}
+ }
+ }
+
+ private fun complete(id: String, content: Any) {
+ send(message, JSONObject().put("type", "TransactionComplete").put("content", content.toString().let { if (content is String) JSONObject.quote(it) else it })
+ .put("id", id).put("cv", "").toString())
+ }
+
+ private fun gamepadChanged(index: Int, added: Boolean) {
+ send(control, JSONObject().put("message", "gamepadChanged").put("gamepadIndex", index).put("wasAdded", added).toString())
+ }
+
+ private fun sendConfig() {
+ fun msg(path: String, content: JSONObject) = JSONObject().put("type", "Message").put("content", content.toString())
+ .put("id", UUID.randomUUID().toString()).put("target", path).put("cv", "").toString()
+ send(message, msg("/streaming/systemUi/configuration", JSONObject().put("version", org.json.JSONArray(listOf(0, 2, 0))).put("systemUis", org.json.JSONArray())))
+ send(message, msg("/streaming/properties/clientappinstallidchanged", JSONObject().put("clientAppInstallId", "c97d7ee0-73b2-4239-bf1d-9d805a338429")))
+ send(message, msg("/streaming/characteristics/orientationchanged", JSONObject().put("orientation", 0)))
+ send(message, msg("/streaming/characteristics/touchinputenabledchanged", JSONObject().put("touchInputEnabled", false)))
+ send(message, msg("/streaming/characteristics/clientdevicecapabilities", JSONObject()))
+ send(message, msg("/streaming/characteristics/dimensionschanged", JSONObject().put("horizontal", 1920).put("vertical", 1080)
+ .put("preferredWidth", 1920).put("preferredHeight", 1080).put("safeAreaLeft", 0).put("safeAreaTop", 0)
+ .put("safeAreaRight", 1920).put("safeAreaBottom", 1080).put("supportsCustomResolution", true)))
+ }
+
+ private fun header(type: Int, size: Int): ByteBuffer = ByteBuffer.allocateDirect(size).order(ByteOrder.LITTLE_ENDIAN).apply {
+ putShort(type.toShort()); putInt(sequence++); putDouble(System.nanoTime() / 1e6)
+ }
+
+ private fun startInput() {
+ // Client metadata: max touch points.
+ send(input, header(8, 15).apply { put(1.toByte()); flip() })
+ loop = timer.scheduleWithFixedDelay({ runCatching { tick() } }, 16, 16, TimeUnit.MILLISECONDS)
+ }
+
+ /** Instant controller input: send a report now if the state changed (the 16 ms loop stays as the heartbeat). */
+ fun inputChanged() {
+ // At most one extra report every 4 ms (stick motion arrives far faster than that).
+ if (handshakeDone && loop != null && System.nanoTime() - lastSentAt > 4_000_000L) timer.execute { runCatching { tick() } }
+ }
+
+ /** Greenlight's queue: a gamepad frame when the state changed (sticks past a small threshold) or every 33 ms. */
+ @Synchronized
+ private fun tick() {
+ val state = pad.snapshot()
+ val now = System.nanoTime()
+ val changed = lastSent?.let { state.differsFrom(it) } ?: true
+ if (!changed && now - lastSentAt < 33_000_000L) return
+ lastSent = state; lastSentAt = now
+ val packet = header(2, 14 + 1 + 23).apply {
+ put(1.toByte()) // one frame
+ put(0.toByte()) // gamepad index
+ putShort(state.buttons.toShort())
+ putShort(axis(state.lx)); putShort(axis(-state.ly)); putShort(axis(state.rx)); putShort(axis(-state.ry))
+ putShort(trigger(state.lt)); putShort(trigger(state.rt))
+ putInt(1) // physical physicality
+ order(ByteOrder.BIG_ENDIAN); putInt(1); order(ByteOrder.LITTLE_ENDIAN) // virtual physicality (Greenlight writes it big-endian)
+ flip()
+ }
+ send(input, packet)
+ }
+
+ private fun axis(value: Float): Short = (value * 32767).toInt().coerceIn(-32767, 32767).toShort()
+ private fun trigger(value: Float): Short = if (value <= 0f) 0 else if (value >= 1f) 65535.toShort() else (value * 65535).toInt().toShort()
+
+ private fun onInputData(buffer: DataChannel.Buffer) {
+ val data = buffer.data.order(ByteOrder.LITTLE_ENDIAN)
+ if (data.remaining() < 13 || (data.get(0).toInt() and 0xFF) != 128) return // vibration report
+ val strong = (data.get(4).toInt() and 0xFF) / 100f
+ val weak = (data.get(5).toInt() and 0xFF) / 100f
+ val duration = data.getShort(8).toInt() and 0xFFFF
+ onRumble(strong, weak, duration)
+ }
+
+ fun close() {
+ loop?.cancel(false)
+ timer.shutdownNow()
+ listOf(chat, control, input, message).forEach { runCatching { it.unregisterObserver(); it.close(); it.dispose() } }
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativeVideo.kt b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativeVideo.kt
new file mode 100644
index 000000000..dc3657dbe
--- /dev/null
+++ b/app/src/main/java/com/droiddeck/launcher/stores/xbox/XboxNativeVideo.kt
@@ -0,0 +1,183 @@
+package com.droiddeck.launcher.stores.xbox
+
+import android.content.Context
+import android.graphics.PixelFormat
+import android.hardware.HardwareBuffer
+import android.media.Image
+import android.media.ImageReader
+import android.os.Handler
+import android.os.HandlerThread
+import android.view.Surface
+import com.droiddeck.launcher.gpu.FrameGen
+import com.droiddeck.launcher.gpu.Lossless
+import com.droiddeck.launcher.wayland.CompositorHost
+import com.droiddeck.launcher.wayland.WaylandCompositor
+import org.webrtc.EglBase
+import org.webrtc.EglRenderer
+import org.webrtc.GlRectDrawer
+import org.webrtc.VideoFrame
+import org.webrtc.VideoSink
+import java.io.File
+import java.util.ArrayDeque
+
+/**
+ * The native player's picture: WebRTC's hardware-decoded frames are drawn (GL, WebRTC's EglRenderer) into an
+ * ImageReader whose images are GPU-shareable HardwareBuffers, and each one goes to DroidDeck's own compositor in
+ * stream mode (WaylandCompositor.nativeStreamFrame) as the whole scene, where DroidDeck's frame generation (DDFG,
+ * or LSFG with the Lossless.dll) multiplies it. The player runs in its own process (:xcloudnative), so this
+ * compositor is never the one a Linux session uses. Android 10+ (ImageReader usage flags).
+ */
+internal class XboxNativeVideo(private val context: Context, eglBase: EglBase, private val log: (String) -> Unit) : VideoSink {
+ companion object {
+ const val FG_OFF = "off"
+ const val FG_DDFG = "ddfg"
+ const val FG_LSFG = "lsfg"
+ const val FG_LSFG3 = "lsfg3"
+ const val FG_LSFG4 = "lsfg4"
+ fun isLsfg(mode: String) = mode == FG_LSFG || mode == FG_LSFG3 || mode == FG_LSFG4
+ fun multiplier(mode: String) = when (mode) { FG_LSFG3 -> 3; FG_LSFG4 -> 4; FG_OFF -> 0; else -> 2 }
+ fun modes(lsfg: Boolean) = if (lsfg) listOf(FG_OFF, FG_DDFG, FG_LSFG, FG_LSFG3, FG_LSFG4) else listOf(FG_OFF, FG_DDFG)
+ fun modeName(mode: String) = when (mode) {
+ FG_DDFG -> "DDFG 2ร"; FG_LSFG -> "LSFG 2ร"; FG_LSFG3 -> "LSFG 3ร"; FG_LSFG4 -> "LSFG 4ร"; else -> "Off"
+ }
+ /** LSFG can run: the Lossless.dll was imported (or found in Steam) and its shader cache built. */
+ fun lsfgReady(context: Context) = Lossless.state(context).ready
+ private const val SCALE_FIT = 1
+ private const val ALIGN_CENTER = 0
+ }
+
+ private val lock = Any()
+ private val egl = EglRenderer("xcloud-native").apply { init(eglBase.eglBaseContext, EglBase.CONFIG_RGBA, GlRectDrawer()) }
+ private val readerThread = HandlerThread("xcloud-frames").apply { start() }
+ private val readerHandler = Handler(readerThread.looper)
+ private var reader: ImageReader? = null
+ private var readerW = 0
+ private var readerH = 0
+ /** Images the compositor may still read: the oldest goes back to the reader once two newer ones arrived. */
+ private val held = ArrayDeque()
+ private var surface: Surface? = null
+ private var attached = false
+ @Volatile private var frameGen = FG_OFF
+ @Volatile private var refreshHz = 60f
+ /** The frame-rate target (0 = the screen's rate): the frame-gen pacer's cap, or a limit on stream frames without it. */
+ @Volatile private var fpsTarget = 0
+ private var nextDueNs = 0L
+ @Volatile var frames = 0L
+ private set
+
+ /** The screen: the compositor presents to it (null detaches, e.g. for zero-copy or when the surface goes). */
+ fun setSurface(target: Surface?, width: Int, height: Int, displayRefreshHz: Float) = synchronized(lock) {
+ refreshHz = displayRefreshHz
+ if (target == null) {
+ if (attached) letGo()
+ attached = false; surface = null
+ return@synchronized
+ }
+ surface = target
+ val runtime = File(context.cacheDir, "xcloud-wayland").apply { mkdirs() }
+ // System Vulkan driver; this process's compositor shows only the stream.
+ val started = CompositorHost.startOrAttach(context, target, runtime.path, null, null, context.applicationInfo.nativeLibraryDir,
+ width, height, displayRefreshHz, 0)
+ if (started) {
+ WaylandCompositor.nativeSetScaleMode(SCALE_FIT, ALIGN_CENTER)
+ log("compositor started on ${width}x$height at ${displayRefreshHz.toInt()} Hz")
+ } else {
+ WaylandCompositor.nativeResizeOutput(width, height)
+ }
+ WaylandCompositor.nativeStreamMode(true)
+ attached = true
+ applyFrameGen()
+ }
+
+ /**
+ * Gives the screen surface up and waits until the compositor has really disconnected from it: the window request
+ * is applied on the compositor thread at its next tick, and the decoder (zero-copy) can only connect after that.
+ */
+ private fun letGo() {
+ WaylandCompositor.nativeSetFrameGenArmed(false, 0, 0)
+ CompositorHost.detach(surface)
+ WaylandCompositor.nativeVsync(System.nanoTime())
+ if (!WaylandCompositor.nativeWaitSurface(1000)) log("compositor did not let go of the screen within 1 s")
+ }
+
+ /** Frame-rate target, 0 = the screen's rate. Live. */
+ fun setFpsTarget(fps: Int) {
+ fpsTarget = fps
+ synchronized(lock) { if (attached) applyFrameGen() }
+ }
+
+ /** off, ddfg (2x) or lsfg / lsfg3 / lsfg4 (needs the Lossless.dll). Live. */
+ fun setFrameGeneration(mode: String) {
+ frameGen = mode
+ synchronized(lock) { if (attached) applyFrameGen() }
+ }
+
+ override fun onFrame(frame: VideoFrame) {
+ val w = frame.rotatedWidth; val h = frame.rotatedHeight
+ synchronized(lock) { if (w != readerW || h != readerH) startReader(w, h) }
+ egl.onFrame(frame)
+ }
+
+ private fun startReader(w: Int, h: Int) {
+ val old = reader
+ if (old != null) egl.releaseEglSurface { }
+ held.forEach { runCatching { it.close() } }; held.clear()
+ old?.close()
+ readerW = w; readerH = h
+ val next = ImageReader.newInstance(w, h, PixelFormat.RGBA_8888, 4,
+ HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE or HardwareBuffer.USAGE_GPU_COLOR_OUTPUT)
+ next.setOnImageAvailableListener({ r -> onImage(r) }, readerHandler)
+ reader = next
+ egl.createEglSurface(next.surface)
+ log("native video ${w}x$h")
+ }
+
+ private fun onImage(r: ImageReader) {
+ val image = runCatching { r.acquireLatestImage() }.getOrNull() ?: return
+ synchronized(lock) {
+ if (r !== reader) { image.close(); return }
+ val buffer = image.hardwareBuffer
+ // Without frame generation a target below the stream's rate drops stream frames evenly: each frame has a
+ // due time one interval after the last one's, so 30 of 60 frames (or 45: three of four) reach the screen.
+ val now = System.nanoTime()
+ val target = fpsTarget
+ val skip = frameGen == FG_OFF && target in 1 until 60 && run {
+ val interval = 1_000_000_000L / target
+ if (nextDueNs == 0L || now - nextDueNs > interval) nextDueNs = now
+ if (now + 4_000_000L < nextDueNs) true else { nextDueNs += interval; false }
+ }
+ if (attached && buffer != null && !skip) { WaylandCompositor.nativeStreamFrame(buffer); frames++ }
+ buffer?.close()
+ held.addLast(image)
+ while (held.size > 2) held.removeFirst().close()
+ }
+ }
+
+ private fun applyFrameGen() {
+ // The pacer caps generated output at this rate: the user's target, else the screen's.
+ val cap = if (fpsTarget > 0) fpsTarget.toFloat() else refreshHz
+ val mode = when {
+ frameGen == FG_DDFG -> FrameGen.Mode(FrameGen.ENGINE_DDFG, 2)
+ isLsfg(frameGen) -> FrameGen.Mode(FrameGen.ENGINE_LSFG, multiplier(frameGen))
+ else -> FrameGen.Mode.OFF
+ }
+ val problem = FrameGen.apply(context, mode, cap)
+ log("native frame generation: $frameGen, target ${if (fpsTarget > 0) fpsTarget else "screen"} fps" + (problem?.let { " ($it)" } ?: ""))
+ }
+
+ /** {planned, planned, sourceFps, presentedFps, thermal, -1}, or null while nothing is shown. */
+ fun stats(): FloatArray? = synchronized(lock) { if (attached) WaylandCompositor.nativeFrameGenStats() else null }
+
+ fun frameGenProblem(): Int = synchronized(lock) { if (attached) WaylandCompositor.nativeFrameGenProblem() else -1 }
+
+ fun release() {
+ egl.release()
+ synchronized(lock) {
+ if (attached) { WaylandCompositor.nativeStreamMode(false); letGo() }
+ attached = false; surface = null
+ held.forEach { runCatching { it.close() } }; held.clear()
+ reader?.close(); reader = null
+ }
+ readerThread.quitSafely()
+ }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt
index 477ab9c50..67d228397 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndGames.kt
@@ -75,16 +75,28 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
val launcherArt by androidx.compose.runtime.produceState(null, epic.version) {
value = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { EpicClient.icon(pageContext, epic.version) }
}
- val games = remember(s.steamGames, s.shortcutPicker, epic, launcherName, launcherArt) {
+ LaunchedEffect(Unit) { com.droiddeck.launcher.stores.xbox.XboxCloudGames.load(pageContext) }
+ val cloudGames = com.droiddeck.launcher.stores.xbox.XboxCloudGames.entries.toList()
+ val games = remember(s.steamGames, s.shortcutPicker, epic, launcherName, launcherArt, cloudGames) {
val sorted = s.steamGames.sortedByDescending { it.lastPlayed }
- if (s.shortcutPicker) sorted else EpicClient.withLauncher(sorted, EpicClient.launcherEntry(pageContext, launcherName, epic, launcherArt))
+ if (s.shortcutPicker) sorted else {
+ val installedXbox = s.steamGames.filter { it.source == com.droiddeck.launcher.stores.Store.XBOX.id }.mapNotNull { it.storeId }.toSet()
+ EpicClient.withLauncher(sorted, EpicClient.launcherEntry(pageContext, launcherName, epic, launcherArt)) +
+ com.droiddeck.launcher.stores.xbox.XboxCloudGames.rows(pageContext, installedXbox)
+ }
}
val gameCount = games.count { it.library != Library.EPIC_LAUNCHER }
val narrow = LocalNarrowPane.current
val current = games.firstOrNull { "app:${it.appId}" == selected } ?: games.firstOrNull()
GamesDialogs(a, s.steamGames)
val onAdd: (() -> Unit)? = if (s.shortcutPicker) null else ({ GamesDialogState.adding = true })
- val launch: (Library.SteamGame) -> Unit = { g -> if (g.library == Library.EPIC_LAUNCHER) a.onEpicLauncher() else a.onSteamGame(g) }
+ val launch: (Library.SteamGame) -> Unit = { g ->
+ when (g.library) {
+ Library.EPIC_LAUNCHER -> a.onEpicLauncher()
+ Library.XBOX_CLOUD -> com.droiddeck.launcher.stores.xbox.XboxCloudGames.stream(pageContext, g.storeId.orEmpty(), g.name)
+ else -> a.onSteamGame(g)
+ }
+ }
val onEdit: (Library.SteamGame) -> Unit = { g ->
g.gameFiles?.let { GamesDialogState.editing = EditRequest(it.path, openTarget = false, fromSummary = false, preview = EditPreview.of(pageContext, g, it.path)) }
}
@@ -120,8 +132,10 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
if (!narrow) Poster(current.art, current.name, Modifier.width(168.dp))
}
}
- Rise(1) { SectionTitle(stringResource(R.string.games_launch_settings), null) }
- Rise(2) { LaunchSettings(s, a, host, current) }
+ if (current.library != Library.XBOX_CLOUD) {
+ Rise(1) { SectionTitle(stringResource(R.string.games_launch_settings), null) }
+ Rise(2) { LaunchSettings(s, a, host, current) }
+ }
Rise(3) { GameFileFolderActions(s, a) }
}
return
@@ -139,8 +153,10 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
GameHeroCopy(current, if (narrow) 24.sp else 32.sp)
GameActions(current, s, a, onEdit, launch)
}
- SectionTitle(stringResource(R.string.games_launch_settings), null)
- LaunchSettings(s, a, host, current)
+ if (current.library != Library.XBOX_CLOUD) {
+ SectionTitle(stringResource(R.string.games_launch_settings), null)
+ LaunchSettings(s, a, host, current)
+ }
GameFileFolderActions(s, a)
}
}
@@ -166,8 +182,25 @@ private fun GameFileFolderActions(s: FrontEndState, a: FrontEndActions) {
@Composable
private fun GameActions(g: Library.SteamGame, s: FrontEndState, a: FrontEndActions, onEdit: (Library.SteamGame) -> Unit, launch: (Library.SteamGame) -> Unit) {
+ val context = androidx.compose.ui.platform.LocalContext.current
+ if (g.library == Library.XBOX_CLOUD) {
+ androidx.compose.runtime.LaunchedEffect(g.gameId) { com.droiddeck.launcher.stores.xbox.XboxCloudGames.prefetch(context) }
+ Actions {
+ PrimaryButton("Stream", enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow) { launch(g) }
+ SecondaryButton("Download") { com.droiddeck.launcher.stores.StoresState.openGameRequest = "${com.droiddeck.launcher.stores.Store.XBOX.id}:${g.storeId}" }
+ SecondaryButton("Remove from Games", compact = true) { com.droiddeck.launcher.stores.xbox.XboxCloudGames.remove(context, g.storeId.orEmpty()) }
+ BusyChip(s)
+ }
+ return
+ }
+ val xboxStream by androidx.compose.runtime.produceState(false, g.gameId) {
+ value = g.source == com.droiddeck.launcher.stores.Store.XBOX.id && !g.storeId.isNullOrEmpty() && !s.shortcutPicker &&
+ kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { com.droiddeck.launcher.stores.xbox.XboxCloudGames.streams(context, g.storeId!!) }
+ }
+ androidx.compose.runtime.LaunchedEffect(xboxStream) { if (xboxStream) com.droiddeck.launcher.stores.xbox.XboxCloudGames.prefetch(context) }
Actions {
PrimaryButton(stringResource(if (s.shortcutPicker) R.string.game_shortcut_choose else R.string.games_launch), enabled = !s.busy, main = true, icon = Icons.Filled.PlayArrow) { launch(g) }
+ if (xboxStream) SecondaryButton("Stream") { com.droiddeck.launcher.stores.xbox.XboxCloudGames.stream(context, g.storeId.orEmpty(), g.name) }
if (g.library == Library.EPIC_LAUNCHER) {
if (!s.shortcutPicker) UninstallEpicLauncher(s, a)
BusyChip(s)
@@ -637,6 +670,7 @@ private fun playedSpan(lastPlayed: Long): String? {
@Composable
private fun libraryLabel(g: Library.SteamGame): String = when {
g.library == Library.EPIC_LAUNCHER -> EpicClient.status.version?.substringBefore('+')?.let { stringResource(R.string.epic_client_version, it) } ?: ""
+ g.library == Library.XBOX_CLOUD -> "Xbox Cloud Gaming"
g.library != Library.ADDED -> if (g.library == "internal") stringResource(R.string.games_internal) else g.library
g.source != Library.ADDED -> stringResource(R.string.games_store_storage)
else -> ""
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt
index 6e44aca35..1294afa9f 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/FrontEndScreen.kt
@@ -395,6 +395,9 @@ private fun FrontEndScreenBody(s: FrontEndState, a: FrontEndActions, page: (@Com
}
a.onNavigated()
}
+ LaunchedEffect(com.droiddeck.launcher.stores.StoresState.openGameRequest) {
+ if (com.droiddeck.launcher.stores.StoresState.openGameRequest != null) selected = "stores"
+ }
var showWirelessAdbFix by rememberSaveable { mutableStateOf(false) }
var showDeveloperDisplayChoice by rememberSaveable { mutableStateOf(false) }
var wirelessAdbDesiredEnabled by rememberSaveable { mutableStateOf(false) }
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt
index b377f02ec..37dbd3cf7 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresDetail.kt
@@ -51,6 +51,8 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import com.droiddeck.launcher.R
+import com.droiddeck.launcher.stores.xbox.xboxCloud
+import com.droiddeck.launcher.stores.xbox.xboxPc
import com.droiddeck.launcher.stores.Store
import com.droiddeck.launcher.stores.StoreAchievements
import com.droiddeck.launcher.stores.StoresState
@@ -127,6 +129,7 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
launchStoreGame(ctx, store, id, s, a)
}
ActionChip(stringResource(R.string.stores_in_steam), ok = true)
+ if (store == Store.XBOX && item?.xboxCloud == true) XboxStreamButton(item)
if (store == Store.XBOX) {
SecondaryButton("Update / verify", enabled = download == null && !com.droiddeck.launcher.stores.xbox.XboxRuntime.isActive(id)) {
runCatching { com.droiddeck.launcher.stores.xbox.XboxBackend.update(ctx, installed) }
@@ -157,6 +160,10 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
com.droiddeck.launcher.stores.download.DownloadQueue.clear(ctx, failedDownload.key)
}
}
+ store == Store.XBOX && item != null && !item.xboxPc && item.xboxCloud -> {
+ XboxStreamButton(item, main = true, modifier = leadOn)
+ XboxAddToGamesButton(item)
+ }
item?.installable == true && StoresState.isUnfinished(item) -> {
InstallButton(stringResource(R.string.stores_resume_install), leadOn) { StoresState.requestInstall(ctx, item) }
ConfirmButton(stringResource(R.string.stores_dl_clear), stringResource(R.string.stores_dl_cancel_confirm), compact = true) { StoresState.clearUnfinished(ctx, item) }
@@ -166,6 +173,10 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
if (item.sizeBytes > 0) stringResource(R.string.stores_install_size, formatBytes(item.sizeBytes)) else stringResource(R.string.stores_install),
leadOn,
) { StoresState.requestInstall(ctx, item) }
+ if (store == Store.XBOX && item.xboxCloud) {
+ XboxStreamButton(item)
+ XboxAddToGamesButton(item)
+ }
if (!item.accessible) {
SecondaryButton(
when {
@@ -290,3 +301,29 @@ private fun InstallButton(label: String, modifier: Modifier = Modifier, onInstal
}
}
}
+
+@Composable
+private fun XboxStreamButton(item: com.droiddeck.launcher.stores.CatalogItem, main: Boolean = false, modifier: Modifier = Modifier) {
+ val ctx = LocalContext.current
+ androidx.compose.runtime.LaunchedEffect(Unit) { com.droiddeck.launcher.stores.xbox.XboxCloudGames.prefetch(ctx) }
+ val stream = { com.droiddeck.launcher.stores.xbox.XboxCloudGames.stream(ctx, item.id, item.title) }
+ if (main) PrimaryButton("Stream", main = true, icon = Icons.Filled.PlayArrow, modifier = modifier) { stream() }
+ else SecondaryButton("Stream", modifier = modifier) { stream() }
+}
+
+@Composable
+private fun XboxAddToGamesButton(item: com.droiddeck.launcher.stores.CatalogItem) {
+ val ctx = LocalContext.current
+ androidx.compose.runtime.LaunchedEffect(Unit) { com.droiddeck.launcher.stores.xbox.XboxCloudGames.load(ctx) }
+ // Add and Remove replace each other; the controller focus moves to the new button instead of getting lost.
+ val slot = remember { androidx.compose.ui.focus.FocusRequester() }
+ val inputMode = androidx.compose.ui.platform.LocalInputModeManager.current
+ val scope = rememberCoroutineScope()
+ fun refocus() { if (inputMode.inputMode == androidx.compose.ui.input.InputMode.Keyboard) scope.launch { focusSoon { slot } } }
+ if (com.droiddeck.launcher.stores.xbox.XboxCloudGames.isAdded(item.id)) {
+ ActionChip("In Games", ok = true)
+ SecondaryButton("Remove from Games", compact = true, modifier = Modifier.focusRequester(slot)) {
+ com.droiddeck.launcher.stores.xbox.XboxCloudGames.remove(ctx, item.id); refocus()
+ }
+ } else SecondaryButton("Add to Games", modifier = Modifier.focusRequester(slot)) { com.droiddeck.launcher.stores.xbox.XboxCloudGames.add(ctx, item); refocus() }
+}
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt
index d443237c5..0634a5271 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/StoresPage.kt
@@ -97,6 +97,8 @@ import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.automirrored.filled.Login
import androidx.compose.material.icons.filled.GridView
import androidx.compose.material.icons.filled.Sort
+import androidx.compose.material.icons.filled.FilterList
+import com.droiddeck.launcher.stores.xbox.XboxLibraryFilter
import androidx.compose.material.icons.filled.ViewList
import androidx.compose.material.icons.filled.ViewModule
import com.droiddeck.launcher.stores.sortCatalogItems
@@ -290,6 +292,11 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier
if (id != DOWNLOADS) tab = tabFor(Store.byId(id), if (s.storesOpenTab == SessionPrefs.STORES_OPEN_STORE) "store" else "library")
move("first")
}
+ LaunchedEffect(StoresState.openGameRequest) {
+ val key = StoresState.openGameRequest ?: return@LaunchedEffect
+ StoresState.openGameRequest = null
+ Store.byId(key.substringBefore(':'))?.let { store -> chip = store.id; tab = tabFor(store, "library"); query = ""; open(key) }
+ }
var focusedChip by remember { mutableStateOf(null) }
LaunchedEffect(focusedChip) {
val id = focusedChip ?: return@LaunchedEffect
@@ -503,7 +510,8 @@ internal fun StoresPage(s: FrontEndState, a: FrontEndActions, modifier: Modifier
if (detailStore != null) Column(
Modifier.fillMaxWidth()
.onFocusChanged { detailFocused = it.hasFocus }
- .focusProperties { exit = { if (coveringNow.value) androidx.compose.ui.focus.FocusRequester.Cancel else androidx.compose.ui.focus.FocusRequester.Default } }
+ // A step opened from the page (install location, uninstall) sits outside it and has to receive the focus.
+ .focusProperties { exit = { if (coveringNow.value && !StoresMotion.stepOpen) androidx.compose.ui.focus.FocusRequester.Cancel else androidx.compose.ui.focus.FocusRequester.Default } }
.focusGroup(),
) { StoreGameDetail(detailStore, key, s, a, heroFocus, onBack = { closeGame() }) }
}
@@ -977,8 +985,10 @@ private fun Storefront(
}
val isSizeSort = tab == "library" && (sort == "sizeAsc" || sort == "sizeDesc")
val browseItems = if (browsing) ItchBrowseState.items else emptyList()
- val candidates = remember(library, everything, tab, q, s.storesShowMature, browsing, browseItems) {
- if (tab == "library") library.filter(::matches)
+ LaunchedEffect(Unit) { XboxLibraryFilter.load(ctx) }
+ val xboxFilter = if (store == Store.XBOX) XboxLibraryFilter.current else XboxLibraryFilter.ALL
+ val candidates = remember(library, everything, tab, q, s.storesShowMature, browsing, browseItems, xboxFilter) {
+ if (tab == "library") library.filter { matches(it) && XboxLibraryFilter.keeps(xboxFilter, it) }
else if (browsing) {
val owned = library.associateBy { it.id }
browseItems.map { owned[it.id] ?: it }.filter(::shown)
@@ -1083,8 +1093,10 @@ private fun Storefront(
item(key = "header", span = { GridItemSpan(maxLineSpan) }) {
Rise(1) {
val tabs = tabsFor(store)
- val libraryLabel = if (loaded) stringResource(R.string.stores_tab_count, stringResource(R.string.stores_tab_library), library.size)
- else stringResource(R.string.stores_tab_library)
+ val libraryName = if (xboxFilter == XboxLibraryFilter.ALL) stringResource(R.string.stores_tab_library)
+ else stringResource(R.string.stores_tab_library) + " ยท " + XboxLibraryFilter.label(xboxFilter)
+ val libraryLabel = if (loaded) stringResource(R.string.stores_tab_count, libraryName, library.count { XboxLibraryFilter.keeps(xboxFilter, it) })
+ else libraryName
ModeHeader(
libraryLabel,
query, onQuery,
@@ -1096,6 +1108,7 @@ private fun Storefront(
} },
) {
if (tab == "library") {
+ if (store == Store.XBOX) XboxFilterButton()
SortButton(s, a)
ViewButton(s, a)
}
@@ -1104,6 +1117,9 @@ private fun Storefront(
else { EpicClient.install(ctx); StoresState.openDownloads = true }
}
SmallTextButton(stringResource(R.string.stores_refresh)) { StoresState.open(ctx, store, force = true) }
+ if (store == Store.XBOX) SmallTextButton("Cloud settings") {
+ ctx.startActivity(android.content.Intent(ctx, com.droiddeck.launcher.stores.xbox.XboxCloudSettingsActivity::class.java))
+ }
val name = StoresState.accounts[store] ?: ""
SignOutButton(store, name)
}
@@ -1335,6 +1351,38 @@ private fun SortButton(s: FrontEndState, a: FrontEndActions) {
}
}
+@Composable
+private fun XboxFilterButton() {
+ val pal = LocalPalette.current
+ val ctx = LocalContext.current
+ val title = "Show"
+ var at by remember { mutableStateOf(null) }
+ Box(Modifier.onGloballyPositioned { at = it.boundsInRoot() }) {
+ HeaderIconButton(title, Icons.Filled.FilterList, "xbox-filter") {
+ val from = at ?: return@HeaderIconButton
+ StoresMotion.ask(StepAsk(
+ anchor = from, side = StepSide.Left, accent = pal.signal, pillCorner = 8.dp, items = XboxLibraryFilter.ALL_FILTERS.size,
+ onDismiss = {},
+ handle = { Text(title, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = pal.signal, maxLines = 1, overflow = TextOverflow.Ellipsis) },
+ ) {
+ StepTitle(title)
+ StepList {
+ XboxLibraryFilter.ALL_FILTERS.forEachIndexed { i, filter ->
+ StepChoice(
+ XboxLibraryFilter.label(filter), selected = filter == XboxLibraryFilter.current, enabled = open,
+ modifier = Modifier.fillMaxWidth().stepItem(i)
+ .then(if (filter == XboxLibraryFilter.current) Modifier.focusRequester(first) else Modifier),
+ ) {
+ XboxLibraryFilter.set(ctx, filter)
+ StoresMotion.fold()
+ }
+ }
+ }
+ })
+ }
+ }
+}
+
@Composable
private fun ViewButton(s: FrontEndState, a: FrontEndActions) {
val views = SessionPrefs.STORES_VIEWS
diff --git a/app/src/main/java/com/droiddeck/launcher/ui/WinComponentsEditor.kt b/app/src/main/java/com/droiddeck/launcher/ui/WinComponentsEditor.kt
index 155c6e9b2..235f2f98b 100644
--- a/app/src/main/java/com/droiddeck/launcher/ui/WinComponentsEditor.kt
+++ b/app/src/main/java/com/droiddeck/launcher/ui/WinComponentsEditor.kt
@@ -56,7 +56,7 @@ import java.io.File
private fun installable(name: String, all: Map): String = WinComponents.installable(name, all)
@Composable
-private fun originName(origin: String): String = stringResource(
+private fun originName(origin: String): String = if (origin == WinCompSources.Origin.XBOX.name) "Xbox" else stringResource(
when (origin) {
WinCompSources.Origin.GOG.name -> R.string.wincomp_src_gog
WinCompSources.Origin.EPIC.name -> R.string.wincomp_src_epic
@@ -71,6 +71,7 @@ private fun autoReason(r: WinComponents.AutoReason): String = when (r.kind) {
WinCompSources.Origin.GOG.name -> stringResource(R.string.wincomp_auto_gog)
WinCompSources.Origin.EPIC.name -> stringResource(R.string.wincomp_auto_epic)
WinCompSources.Origin.AMAZON.name -> stringResource(R.string.wincomp_auto_amazon)
+ WinCompSources.Origin.XBOX.name -> "Xbox games install it with the game (${r.args.getOrElse(0) { "VC14" }})"
else -> ""
}
diff --git a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java
index 48d9b3576..4111cea2c 100644
--- a/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java
+++ b/app/src/main/java/com/droiddeck/launcher/wayland/WaylandCompositor.java
@@ -307,6 +307,13 @@ public final class WaylandCompositor {
public static native float[] nativeFrameGenStats();
+ /** Stream mode: the scene is the Xbox Cloud Gaming native player's picture, handed over frame by frame. */
+ public static native void nativeStreamMode(boolean on);
+
+ public static native void nativeStreamFrame(android.hardware.HardwareBuffer buffer);
+
+ public static native int nativeStreamFrames();
+
public static void sendPointerDelta(int dx, int dy) {
if (dx == 0 && dy == 0) return;
nativeSendSceneInput(6, dx * 256, dy * 256);
diff --git a/app/src/main/java/org/webrtc/DroidDeckLowLatencyDecoderFactory.java b/app/src/main/java/org/webrtc/DroidDeckLowLatencyDecoderFactory.java
new file mode 100644
index 000000000..2a196ed2d
--- /dev/null
+++ b/app/src/main/java/org/webrtc/DroidDeckLowLatencyDecoderFactory.java
@@ -0,0 +1,257 @@
+package org.webrtc;
+
+import android.graphics.ImageFormat;
+import android.media.Image;
+import android.media.ImageReader;
+import android.media.MediaCodec;
+import android.media.MediaCodecInfo;
+import android.media.MediaCodecList;
+import android.media.MediaCrypto;
+import android.media.MediaFormat;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.HandlerThread;
+import android.os.SystemClock;
+import android.view.Surface;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.HashMap;
+
+/**
+ * DroidDeck's hardware video decoder factory for the native Xbox Cloud Gaming player. It builds WebRTC's own
+ * AndroidVideoDecoder (hence this package) around a MediaCodec wrapper that can:
+ * - ask the decoder for low-latency output (Android's KEY_LOW_LATENCY and Qualcomm's low-latency / picture-order
+ * vendor keys; unknown keys are ignored, and a decoder that refuses them is configured without them);
+ * - for zero-copy, decode straight onto the player's SurfaceView instead of WebRTC's texture, rendering every
+ * output buffer there (no GL copy, no compositor; WebRTC then gets no decoded frames to draw).
+ * Without either option it behaves like WebRTC's HardwareVideoDecoderFactory.
+ * The route is switchable while the decoder runs (setOutputSurface): {@link #useScreen} sends frames straight to the
+ * screen (zero-copy) or, with null, back to WebRTC's texture (for the Vulkan compositor and frame generation);
+ * {@link #setZeroCopySurface} moves a zero-copy decoder to a stand-in surface while the screen surface is gone.
+ * The factory also reports the decoded size and the decode time (input queued to output ready), which WebRTC's
+ * stats lack in zero-copy.
+ */
+public class DroidDeckLowLatencyDecoderFactory implements VideoDecoderFactory {
+ /** Counts frames sent to the display in zero-copy mode (WebRTC's own stats see none). */
+ public interface FrameCounter { void onFrameShown(); }
+
+ private final HardwareVideoDecoderFactory hardware;
+ private final EglBase.Context sharedContext;
+ private final boolean lowLatency;
+ /** Frames go to {@link #zeroCopySurface} (the screen or the stand-in), not WebRTC's texture. */
+ private volatile boolean zeroCopy;
+ private volatile Surface zeroCopySurface;
+ /** Back on WebRTC's texture: render the next buffer there, so WebRTC's pending texture frame completes. */
+ private volatile boolean renderNext;
+ private final FrameCounter counter;
+ private volatile Wrapper live;
+ private ImageReader standIn;
+ private HandlerThread standInThread;
+ private volatile int width, height;
+ private final Object decodeLock = new Object();
+ private final HashMap queuedAt = new HashMap<>();
+ private double decodeSumMs;
+ private int decodeCount;
+
+ public DroidDeckLowLatencyDecoderFactory(EglBase.Context sharedContext, boolean lowLatency, Surface zeroCopySurface, FrameCounter counter) {
+ this.hardware = new HardwareVideoDecoderFactory(sharedContext);
+ this.sharedContext = sharedContext;
+ this.lowLatency = lowLatency;
+ this.zeroCopy = zeroCopySurface != null;
+ this.zeroCopySurface = zeroCopySurface;
+ this.counter = counter;
+ }
+
+ /**
+ * Zero-copy: the surface to decode onto from now on; null = a stand-in that drops every frame (the screen
+ * surface is being destroyed). Must be called before a destroyed surface's callback returns.
+ */
+ public synchronized void setZeroCopySurface(Surface surface) {
+ if (!zeroCopy) return;
+ moveTo(surface != null ? surface : standInSurface(), surface != null ? "the screen" : "a stand-in");
+ }
+
+ /**
+ * Switch the route live: a surface = zero-copy onto it; null = WebRTC's texture again. Returns false when the
+ * decoder refused the new surface (the route is then unchanged).
+ */
+ public synchronized boolean useScreen(Surface screen) {
+ if (screen != null) {
+ Wrapper w = live;
+ if (w != null && !moveTo(screen, "the screen")) return false;
+ zeroCopySurface = screen;
+ zeroCopy = true;
+ return true;
+ }
+ if (!zeroCopy) return true;
+ Wrapper w = live;
+ if (w != null && w.texture != null && !moveTo(w.texture, "WebRTC's texture")) return false;
+ zeroCopy = false;
+ renderNext = true;
+ return true;
+ }
+
+ private boolean moveTo(Surface target, String what) {
+ Wrapper w = live;
+ if (w == null || target == null) { if (zeroCopy) zeroCopySurface = target; return true; }
+ try {
+ w.raw.setOutputSurface(target);
+ if (zeroCopy) zeroCopySurface = target;
+ Logging.d("DroidDeckDecoder", "decoder output moved to " + what);
+ return true;
+ } catch (RuntimeException e) {
+ Logging.w("DroidDeckDecoder", "decoder output change to " + what + " failed: " + e);
+ return false;
+ }
+ }
+
+ private Surface standInSurface() {
+ if (standIn == null) {
+ standInThread = new HandlerThread("bl-decoder-standin");
+ standInThread.start();
+ standIn = ImageReader.newInstance(Math.max(width, 16), Math.max(height, 16), ImageFormat.PRIVATE, 3);
+ standIn.setOnImageAvailableListener(reader -> {
+ Image image = reader.acquireLatestImage();
+ if (image != null) image.close();
+ }, new Handler(standInThread.getLooper()));
+ }
+ return standIn.getSurface();
+ }
+
+ /** The decoder's picture size, or null before the first decoder is configured. */
+ public String videoSize() {
+ return width > 0 ? width + "x" + height : null;
+ }
+
+ /** Average decode time since the last call, in ms (-1 when no frame was decoded in between). */
+ public double takeDecodeMs() {
+ synchronized (decodeLock) {
+ double avg = decodeCount > 0 ? decodeSumMs / decodeCount : -1;
+ decodeSumMs = 0; decodeCount = 0;
+ return avg;
+ }
+ }
+
+ public synchronized void release() {
+ if (standIn != null) { standIn.close(); standIn = null; }
+ if (standInThread != null) { standInThread.quitSafely(); standInThread = null; }
+ }
+
+ @Override
+ public VideoCodecInfo[] getSupportedCodecs() {
+ return hardware.getSupportedCodecs();
+ }
+
+ @Override
+ public VideoDecoder createDecoder(VideoCodecInfo info) {
+ VideoCodecMimeType type;
+ try { type = VideoCodecMimeType.valueOf(info.getName()); } catch (IllegalArgumentException e) { return hardware.createDecoder(info); }
+ MediaCodecInfo codec = findHardwareDecoder(type);
+ if (codec == null) return hardware.createDecoder(info);
+ Integer color = MediaCodecUtils.selectColorFormat(MediaCodecUtils.DECODER_COLOR_FORMATS, codec.getCapabilitiesForType(type.mimeType()));
+ if (color == null) return hardware.createDecoder(info);
+ return new AndroidVideoDecoder(new WrapperFactory(), codec.getName(), type, color, sharedContext);
+ }
+
+ private static MediaCodecInfo findHardwareDecoder(VideoCodecMimeType type) {
+ for (MediaCodecInfo info : new MediaCodecList(MediaCodecList.REGULAR_CODECS).getCodecInfos()) {
+ if (info.isEncoder()) continue;
+ if (MediaCodecUtils.codecSupportsType(info, type) && MediaCodecUtils.isHardwareAccelerated(info)) return info;
+ }
+ return null;
+ }
+
+ private class WrapperFactory implements MediaCodecWrapperFactory {
+ @Override
+ public MediaCodecWrapper createByCodecName(String name) throws IOException {
+ return new Wrapper(MediaCodec.createByCodecName(name));
+ }
+ }
+
+ /** MediaCodec itself (not WebRTC's wrapper) so the output surface can be changed while it runs. */
+ private class Wrapper implements MediaCodecWrapper {
+ final MediaCodec raw;
+ /** WebRTC's texture surface, the route back from zero-copy. */
+ Surface texture;
+ Wrapper(MediaCodec raw) { this.raw = raw; }
+
+ @Override
+ public void configure(MediaFormat format, Surface surface, MediaCrypto crypto, int flags) {
+ width = format.getInteger(MediaFormat.KEY_WIDTH);
+ height = format.getInteger(MediaFormat.KEY_HEIGHT);
+ texture = surface;
+ live = this;
+ Surface target = surface;
+ if (zeroCopy) { synchronized (DroidDeckLowLatencyDecoderFactory.this) { target = zeroCopySurface != null ? zeroCopySurface : standInSurface(); } }
+ if (lowLatency) {
+ MediaFormat fast = MediaFormat.createVideoFormat(format.getString(MediaFormat.KEY_MIME), width, height);
+ copyInt(format, fast, MediaFormat.KEY_COLOR_FORMAT);
+ if (Build.VERSION.SDK_INT >= 30) fast.setInteger(MediaFormat.KEY_LOW_LATENCY, 1);
+ fast.setInteger(MediaFormat.KEY_PRIORITY, 0);
+ fast.setInteger("vendor.qti-ext-dec-low-latency.enable", 1);
+ fast.setInteger("vendor.qti-ext-dec-picture-order.enable", 1);
+ try {
+ raw.configure(fast, target, crypto, flags);
+ Logging.d("DroidDeckDecoder", "low-latency decoder configured" + (zeroCopy ? " (zero-copy)" : ""));
+ return;
+ } catch (RuntimeException e) {
+ Logging.w("DroidDeckDecoder", "decoder refused low-latency keys: " + e);
+ }
+ }
+ raw.configure(format, target, crypto, flags);
+ }
+
+ private void copyInt(MediaFormat from, MediaFormat to, String key) {
+ if (from.containsKey(key)) to.setInteger(key, from.getInteger(key));
+ }
+
+ // Zero-copy: AndroidVideoDecoder renders one buffer to its texture and drops the rest until the texture
+ // hands the frame back, which never happens with the display surface; show every buffer instead.
+ @Override public void releaseOutputBuffer(int index, boolean render) {
+ if (zeroCopy) { raw.releaseOutputBuffer(index, true); if (counter != null) counter.onFrameShown(); }
+ else if (renderNext) { renderNext = false; raw.releaseOutputBuffer(index, true); }
+ else raw.releaseOutputBuffer(index, render);
+ }
+
+ @Override public void queueInputBuffer(int index, int offset, int size, long presentationTimeUs, int flags) {
+ synchronized (decodeLock) {
+ if (queuedAt.size() > 64) queuedAt.clear();
+ queuedAt.put(presentationTimeUs, SystemClock.elapsedRealtimeNanos());
+ }
+ raw.queueInputBuffer(index, offset, size, presentationTimeUs, flags);
+ }
+
+ @Override public int dequeueOutputBuffer(MediaCodec.BufferInfo info, long timeoutUs) {
+ int index = raw.dequeueOutputBuffer(info, timeoutUs);
+ if (index >= 0) synchronized (decodeLock) {
+ Long at = queuedAt.remove(info.presentationTimeUs);
+ if (at != null) { decodeSumMs += (SystemClock.elapsedRealtimeNanos() - at) / 1e6; decodeCount++; }
+ }
+ return index;
+ }
+
+ @Override public MediaFormat getOutputFormat() {
+ MediaFormat f = raw.getOutputFormat();
+ if (f.containsKey(MediaFormat.KEY_WIDTH) && f.containsKey(MediaFormat.KEY_HEIGHT)) {
+ width = f.containsKey("crop-right") ? f.getInteger("crop-right") - f.getInteger("crop-left") + 1 : f.getInteger(MediaFormat.KEY_WIDTH);
+ height = f.containsKey("crop-bottom") ? f.getInteger("crop-bottom") - f.getInteger("crop-top") + 1 : f.getInteger(MediaFormat.KEY_HEIGHT);
+ }
+ return f;
+ }
+
+ @Override public void start() { raw.start(); }
+ @Override public void flush() { synchronized (decodeLock) { queuedAt.clear(); } raw.flush(); }
+ @Override public void stop() { raw.stop(); }
+ @Override public void release() { if (live == this) live = null; raw.release(); }
+ @Override public int dequeueInputBuffer(long timeoutUs) { return raw.dequeueInputBuffer(timeoutUs); }
+ @Override public MediaFormat getInputFormat() { return raw.getInputFormat(); }
+ @Override public MediaFormat getOutputFormat(int index) { return raw.getOutputFormat(index); }
+ @Override public ByteBuffer getInputBuffer(int index) { return raw.getInputBuffer(index); }
+ @Override public ByteBuffer getOutputBuffer(int index) { return raw.getOutputBuffer(index); }
+ @Override public Surface createInputSurface() { return raw.createInputSurface(); }
+ @Override public void setParameters(Bundle params) { raw.setParameters(params); }
+ @Override public MediaCodecInfo getCodecInfo() { return raw.getCodecInfo(); }
+ }
+}
diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/WinCompSourcesTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/WinCompSourcesTest.kt
index 78ac65445..b6c98b00a 100644
--- a/app/src/test/java/com/droiddeck/launcher/frontend/WinCompSourcesTest.kt
+++ b/app/src/test/java/com/droiddeck/launcher/frontend/WinCompSourcesTest.kt
@@ -56,6 +56,17 @@ class WinCompSourcesTest {
assertEquals(emptyList(), WinCompSources.epic("Installers/LauncherHelper.exe", "Launcher Helper"))
}
+ @Test fun xboxGamesAlwaysGetVisualCppAndTheirDeclaredRuntimes() {
+ val folder = tmp.newFolder("xbox")
+ val vc = listOf("vcredist2022" to "VC14", "vcredist2022_arm64" to "VC14")
+ assertEquals(vc, WinCompSources.xbox(folder))
+ File(folder, "MicrosoftGame.Config").writeText("""""")
+ assertEquals(vc, WinCompSources.xbox(folder))
+ sidecar(folder, Store.XBOX, emptyMap())
+ assertEquals(listOf("vcredist2022" to WinCompSources.Origin.XBOX, "vcredist2022_arm64" to WinCompSources.Origin.XBOX),
+ auto(WinCompSources.forGame(app, game(folder, Store.XBOX.id))).distinct())
+ }
+
@Test fun steamworksSharedDepotsMapToComponents() {
assertEquals(listOf("vcredist2015_dll" to "VC++ 2015", "d3dx9" to "DirectX (June 2010)"), WinCompSources.steamDepots(listOf("228986", "228990", "999")))
}
diff --git a/app/src/test/java/com/droiddeck/launcher/session/WinComponentsSupportTest.kt b/app/src/test/java/com/droiddeck/launcher/session/WinComponentsSupportTest.kt
index 656c56c9a..831173699 100644
--- a/app/src/test/java/com/droiddeck/launcher/session/WinComponentsSupportTest.kt
+++ b/app/src/test/java/com/droiddeck/launcher/session/WinComponentsSupportTest.kt
@@ -31,6 +31,14 @@ class WinComponentsSupportTest {
step("install_msi", "url" to "$base/ps-x86.msi"), step("install_msi", "url" to "$base/ps-x64.msi"))))
}
+ @Test fun builtInArm64RuntimeIsRegistryOnlyInBothViews() {
+ val c = WinComponents.builtInArm64Runtime()
+ assertEquals(Support.READY, support(c))
+ assertEquals(12, c.steps.count { it.action == "set_register_key" })
+ assertEquals(setOf("HKLM\\Software\\Microsoft\\VisualStudio\\14.0\\VC\\Runtimes\\arm64",
+ "HKLM\\Software\\Wow6432Node\\Microsoft\\VisualStudio\\14.0\\VC\\Runtimes\\arm64"), c.steps.map { it.str("key") }.toSet())
+ }
+
@Test fun installerWrappersInstallHere() {
assertEquals(Support.READY, support(component("vcredist2022",
step("install_exe", "url" to "$base/vcredist2022__VC_redist.x86.exe", "file_name" to "VC_redist.x86.exe"),
diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt
index c4f95c091..a59450e34 100644
--- a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt
+++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCatalogTest.kt
@@ -83,13 +83,14 @@ class XboxCatalogTest {
assertEquals(mapOf(game to "gamepass", parent to "gamepass"), access(row(game, parent), row(parent, plan), row(plan)))
}
- @Test fun onlyPcPlansGrantSubscriptionAccess() {
- for (pc in listOf("CFQ7TTC0KGQ8", "CFQ7TTC0KHS0", "CFQ7TTC0P85B", "CFQ7TTC0K5DJ", "CFQ7TTC10QFD")) {
- assertEquals(mapOf(game to "gamepass"), access(row(game, pc), row(pc)))
+ @Test fun everyActivePlanGrantsSubscriptionAccess() {
+ for (plan in listOf("CFQ7TTC0KGQ8", "CFQ7TTC0KHS0", "CFQ7TTC0P85B", "CFQ7TTC0K5DJ", "CFQ7TTC10QFD", "CFQ7TTC0K6L8")) {
+ assertEquals(mapOf(game to "gamepass"), access(row(game, plan), row(plan)))
}
val console = "CFQ7TTC0K6L8"
- assertTrue(access(row(game, console), row(console)).isEmpty())
- assertTrue(XboxCatalog.access(withMembership(envelope(row(game, console)), membership(console)),
+ assertEquals(mapOf(game to "gamepass"), XboxCatalog.access(withMembership(envelope(row(game, console)), membership(console)),
+ "test@example.test", "US", now))
+ assertTrue(XboxCatalog.access(withMembership(envelope(row(game, console)), membership(console).put("status", "Expired")),
"test@example.test", "US", now).isEmpty())
}
@@ -165,4 +166,29 @@ class XboxCatalogTest {
assertEquals(game, XboxCatalog.product(product("Windows.Desktop", "MSIXVC").put("ProductId", game.lowercase()), mapOf(game to "owned"))!!.id)
assertTrue(pc.accessible); assertTrue(pc.subscription); assertFalse(pc.owned)
}
+
+ private fun product(id: String, platform: String?) = JSONObject().put("ProductId", id).put("ProductType", "Game")
+ .put("LocalizedProperties", JSONArray().put(JSONObject().put("ProductTitle", "A Game")))
+ .put("DisplaySkuAvailabilities", JSONArray().put(JSONObject().put("Sku", JSONObject().put("Properties", JSONObject()
+ .put("Packages", if (platform == null) JSONArray() else JSONArray().put(JSONObject().put("PackageFormat", "MSIXVC")
+ .put("PlatformDependencies", JSONArray().put(JSONObject().put("PlatformName", platform)))))))))
+
+ @Test fun cloudListsKeepStreamOnlyGamesAndMarkStreamablePcGames() {
+ val cloud = XboxCloudCatalog(gamePass = setOf(game), all = setOf(game, parent))
+ val pc = XboxCatalog.product(product(game, "Windows.Desktop"), mapOf(game to "gamepass"), cloud)!!
+ assertTrue(pc.xboxPc); assertTrue(pc.xboxCloud)
+ val consoleOnly = XboxCatalog.product(product(parent, "Xbox.One"), mapOf(parent to "owned"), cloud)!!
+ assertFalse(consoleOnly.xboxPc); assertTrue(consoleOnly.xboxCloud); assertEquals(0L, consoleOnly.sizeBytes)
+ assertNull(XboxCatalog.product(product(parent, "Xbox.One"), mapOf(parent to "gamepass"), cloud))
+ assertNull(XboxCatalog.product(product(parent, "Xbox.One"), mapOf(parent to "owned"), null))
+ assertFalse(XboxCatalog.product(product(game, "Windows.Desktop"), mapOf(game to "gamepass"), null)!!.xboxCloud)
+ }
+
+ @Test fun aCloudOnlyEditionFoldsIntoThePcCardWithTheSameTitle() {
+ val cloud = XboxCloudCatalog(gamePass = setOf(parent), all = emptySet())
+ val pc = XboxCatalog.product(product(game, "Windows.Desktop"), mapOf(game to "gamepass"), cloud)!!
+ val console = XboxCatalog.product(product(parent, "Xbox.One"), mapOf(parent to "gamepass"), cloud)!!
+ val merged = mergeEditions(listOf(pc, console)).single()
+ assertEquals(game, merged.id); assertTrue(merged.xboxPc); assertTrue(merged.xboxCloud); assertEquals(parent, merged.xboxCloudId)
+ }
}
diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCloudTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCloudTest.kt
new file mode 100644
index 000000000..bbe2e11f9
--- /dev/null
+++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxCloudTest.kt
@@ -0,0 +1,49 @@
+package com.droiddeck.launcher.stores.xbox
+
+import com.droiddeck.launcher.stores.CatalogItem
+import com.droiddeck.launcher.stores.Store
+import org.junit.Assert.*
+import org.junit.Test
+
+@org.junit.runner.RunWith(org.robolectric.RobolectricTestRunner::class)
+@org.robolectric.annotation.Config(sdk = [33], application = android.app.Application::class)
+class XboxCloudTest {
+ @Test fun settingsRoundTripAndOutOfRangeValuesAreClamped() {
+ val settings = XboxCloudSettings(resolution = "720p", bitrateMbps = 20, volume = 200, sharpen = 4, fxaa = true, statsCorner = "left",
+ playerEngine = "native", frameGen = "lsfg3", fpsLimit = 120, fpsCustomOn = true, fpsCustom = 90, smallBuffer = true, zeroCopy = true)
+ assertEquals(settings, XboxCloudSettings.decode(settings.toJson()))
+ val wild = XboxCloudSettings.decode("""{"resolution":"4k","codec":"x","bitrateMbps":900,"volume":5,"statsLevel":9,"gamma":40}""")
+ assertEquals("1080p", wild.resolution); assertEquals("default", wild.codec); assertEquals(100, wild.bitrateMbps)
+ assertEquals(100, wild.volume); assertEquals(4, wild.statsLevel); assertEquals(3f, wild.gamma)
+ assertEquals("right", XboxCloudSettings.decode("""{"statsCorner":"middle"}""").statsCorner)
+ assertEquals("ddfg", XboxCloudSettings.decode("""{"frameGen":"winfg"}""").frameGen)
+ assertEquals(0, XboxCloudSettings.decode("""{"fpsLimit":77,"fpsCustom":500}""").fpsLimit)
+ assertEquals(165, XboxCloudSettings.decode("""{"fpsCustomOn":true,"fpsCustom":500}""").fpsTarget)
+ assertEquals(XboxCloudSettings(), XboxCloudSettings.decode("not json"))
+ assertEquals("android", XboxCloudSettings(resolution = "720p").osName)
+ assertEquals("tizen", XboxCloudSettings(resolution = "1080p_hq").osName)
+ }
+
+ @Test fun addedCloudGamesKeepValidRowsOnly() {
+ val list = listOf(XboxCloudGames.Entry("9AAAAAAAAAAA", "A game", "https://store-images.s-microsoft.com/p", null))
+ val decoded = XboxCloudGames.decode(XboxCloudGames.encode(list))
+ assertEquals(1, decoded.size); assertEquals("A game", decoded[0].title); assertNull(decoded[0].hero)
+ assertTrue(XboxCloudGames.decode("""[{"id":"bad","title":"x"},{"id":"9BBBBBBBBBBB","title":""}]""").isEmpty())
+ assertTrue(XboxCloudGames.decode("nope").isEmpty())
+ assertTrue(XboxCloudGames.rowId("9AAAAAAAAAAA") < 0)
+ }
+
+ @Test fun libraryFilterSplitsOwnedGamePassAndCloud() {
+ fun item(owned: Boolean, pass: Boolean, cloud: Boolean) = CatalogItem(Store.XBOX, "9AAAAAAAAAAA", "A", null,
+ owned = owned, subscription = pass, extra = mapOf("cloud" to if (cloud) "1" else "0"))
+ val owned = item(true, false, false); val pass = item(false, true, true)
+ assertTrue(XboxLibraryFilter.keeps(XboxLibraryFilter.ALL, owned))
+ assertTrue(XboxLibraryFilter.keeps(XboxLibraryFilter.OWNED, owned)); assertFalse(XboxLibraryFilter.keeps(XboxLibraryFilter.OWNED, pass))
+ assertTrue(XboxLibraryFilter.keeps(XboxLibraryFilter.GAME_PASS, pass)); assertFalse(XboxLibraryFilter.keeps(XboxLibraryFilter.GAME_PASS, owned))
+ assertTrue(XboxLibraryFilter.keeps(XboxLibraryFilter.CLOUD, pass)); assertFalse(XboxLibraryFilter.keeps(XboxLibraryFilter.CLOUD, owned))
+ }
+
+ @Test fun cloudLaunchUrlUsesTheStreamingEdition() {
+ assertEquals("https://www.xbox.com/play/launch/ball-x-pit/9BBBBBBBBBBB", XboxCatalog.cloudUrl("9AAAAAAAAAAA", "BALL x PIT", "9BBBBBBBBBBB"))
+ }
+}
diff --git a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCacheTest.kt b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCacheTest.kt
index 8d545d20c..ca0735091 100644
--- a/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCacheTest.kt
+++ b/app/src/test/java/com/droiddeck/launcher/stores/xbox/XboxLibraryCacheTest.kt
@@ -13,7 +13,7 @@ class XboxLibraryCacheTest {
private fun game(id: String, subscription: Boolean) = CatalogItem(Store.XBOX, id, "A game", "https://example.test/hero",
tallImageUrl = "https://example.test/poster", description = "Description", developer = "Developer", publisher = "Publisher",
sizeBytes = 12345, owned = !subscription, subscription = subscription, storeUrl = "https://www.xbox.com/games/store/$id",
- extra = mapOf("market" to "US", "access" to if (subscription) "gamepass" else "owned"))
+ extra = mapOf("market" to "US", "access" to if (subscription) "gamepass" else "owned", "pc" to "1", "cloud" to "0", "cloudId" to ""))
@Test fun restartRestoresMetadataWithoutTurningSubscriptionsIntoPurchases() {
val games = listOf(game("9AAAAAAAAAAA", false), game("9BBBBBBBBBBB", true))
@@ -28,7 +28,7 @@ class XboxLibraryCacheTest {
val text = XboxLibraryCache.encode(account, "US", listOf(game("9AAAAAAAAAAA", false)), now)
assertNull(XboxLibraryCache.decode(text, "other@example.test", "US", now))
assertNull(XboxLibraryCache.decode(text, account, "GB", now))
- assertNull(XboxLibraryCache.decode(text.replace("\"schema\":1", "\"schema\":2"), account, "US", now))
+ assertNull(XboxLibraryCache.decode(text.replace("\"schema\":2", "\"schema\":1"), account, "US", now))
assertNull(XboxLibraryCache.decode("invalid", account, "US", now))
assertNull(XboxLibraryCache.decode(text, account, "US", now - 120_000))
}
@@ -50,4 +50,13 @@ class XboxLibraryCacheTest {
XboxLibraryCache.clear(app)
assertNull(XboxLibraryCache.read(app, account, "US"))
}
+
+ @Test fun cloudFlagsAndTheStreamingEditionSurviveARestart() {
+ val stream = game("9CCCCCCCCCCC", true).copy(extra = mapOf("market" to "US", "access" to "gamepass", "pc" to "0", "cloud" to "1", "cloudId" to ""))
+ val folded = game("9AAAAAAAAAAA", true).copy(extra = mapOf("market" to "US", "access" to "gamepass", "pc" to "1", "cloud" to "1", "cloudId" to "9DDDDDDDDDDD"))
+ val cached = XboxLibraryCache.decode(XboxLibraryCache.encode(account, "US", listOf(stream, folded), now), account, "US", now)!!
+ assertEquals(listOf(stream, folded), cached.games)
+ assertFalse(cached.games[0].xboxPc); assertTrue(cached.games[0].xboxCloud)
+ assertEquals("9DDDDDDDDDDD", cached.games[1].xboxCloudId)
+ }
}
diff --git a/tools/tests/test_output_refresh.py b/tools/tests/test_output_refresh.py
index 7b252649e..456e230d4 100644
--- a/tools/tests/test_output_refresh.py
+++ b/tools/tests/test_output_refresh.py
@@ -66,6 +66,8 @@ static void send_toplevel_configure(struct surface *s) { (void)s; resizes++; }
#define pointer_button(...) ((void)0)
#define scroll_event(...) ((void)0)
#define axis_event(...) ((void)0)
+#define stream_take(...) ((void)0)
+#define stream_forget_all(...) ((void)0)
#define pointer_delta(...) ((void)0)
#define deliver_touch(m) ((void)0)
#define deliver_pointer(m) (pointers++)
diff --git a/tools/xbox/README.md b/tools/xbox/README.md
index 604e8e4fe..986fc1bd7 100644
--- a/tools/xbox/README.md
+++ b/tools/xbox/README.md
@@ -7,6 +7,12 @@ checksums. `runtime.lock.json` pins the Nexus Windows runtime release and each
extracted PE DLL. `prepare.py` verifies both source revisions and all Nexus patch
checksums before applying the local transport, launch and library patches.
+`droiddeck-cloud-token.patch` adds `xodus-cli cloud-token`: the stored account's
+Xbox Cloud Gaming sign-in (XSTS for `http://gssv.xboxlive.com/`, then the streaming
+service's `/v2/login/user`) and the login proof token a session's `/connect` step
+takes. It prints one JSON object and never opens a login or provisions a device.
+Ported from Bannerlator.
+
`droiddeck-library-subscriptions.patch` queries fresh subscription membership
alongside product entitlements using the same Store session, account and market.
Android can validate a Game Pass child's parent plan when that plan is absent
diff --git a/tools/xbox/droiddeck-cloud-token.patch b/tools/xbox/droiddeck-cloud-token.patch
new file mode 100644
index 000000000..57a0b6172
--- /dev/null
+++ b/tools/xbox/droiddeck-cloud-token.patch
@@ -0,0 +1,171 @@
+diff --git a/crates/xodus-cli/src/commands.rs b/crates/xodus-cli/src/commands.rs
+index 5a66715..5b310e4 100644
+--- a/crates/xodus-cli/src/commands.rs
++++ b/crates/xodus-cli/src/commands.rs
+@@ -6,6 +6,7 @@ pub mod license;
+ pub mod login;
+ pub mod logout;
+ pub mod profile;
++pub mod cloud_token;
+ pub mod game_activity;
+ pub mod achievements;
+ pub mod subscription;
+diff --git a/crates/xodus-cli/src/commands/cloud_token.rs b/crates/xodus-cli/src/commands/cloud_token.rs
+new file mode 100644
+index 0000000..fbd0ed0
+--- /dev/null
++++ b/crates/xodus-cli/src/commands/cloud_token.rs
+@@ -0,0 +1,124 @@
++//! Xbox Cloud Gaming streaming token for the stored account (DroidDeck). Same bearer
++//! RPS -> Xbox user -> XSTS exchange as the profile read, for the cloud gaming relying party,
++//! then the streaming service's user login (as xbox.com and Greenlight's xal-node do).
++//! Never provisions a device or opens login.
++use std::{process::ExitCode, time::Duration};
++use serde_json::{Value, json};
++use xodus::{models::{live::ExchangeUserTokenOutcome, secrets::Token, soap}, tokens::TokenManager};
++
++const GSSV_RELYING_PARTY: &str = "http://gssv.xboxlive.com/";
++const OFFERINGS: [&str; 2] = ["xgpuweb", "xgpuwebf2p"];
++
++/// The stored account's compact Microsoft account ticket for one service target and policy.
++async fn ticket(tokens: &TokenManager, client: &reqwest::Client, target: &str, policy: soap::PolicyReference) -> Result<(String, String), &'static str> {
++ let (account, device, user) = super::session::prepare(tokens, client).await.map_err(|stage| {
++ super::session::diagnostic(stage); stage
++ })?;
++ let outcome = super::session::exchange(|| async { xodus::api::live::exchange_user_token(client, user.clone(), account.clone(), device.clone(),
++ None, Some("Silent".into()), "{d6d5a677-0872-4ab0-9442-bb792fce85c5}".into(),
++ &[(target.into(), Some(policy.clone()))])
++ .await.map_err(|error| super::session::classify(&error)) }).await?;
++ let response = match outcome {
++ ExchangeUserTokenOutcome::Issued(soap::BodyContent::RequestSecurityTokenResponseCollection(mut collection)) if !collection.security_tokens.is_empty() => collection.security_tokens.remove(0),
++ ExchangeUserTokenOutcome::Issued(soap::BodyContent::RequestSecurityTokenResponse(response)) => *response,
++ _ => return Err("msa-response"),
++ };
++ // Token::from panics on token types it does not know; only take the compact forms here. The cloud
++ // transfer purpose answers with a login proof token, which is what /connect takes (Greenlight
++ // sends the MSAL response's "lpt").
++ let kind = response.token_type.clone();
++ if kind == "urn:passport:loginprooftoken" {
++ let shape = match (&response.requested_security_token.binary_security_token, &response.requested_security_token.encrypted_data) {
++ (Some(token), _) if !token.value.is_empty() => "binary",
++ (_, Some(_)) => "encrypted",
++ _ => "empty",
++ };
++ eprintln!("NEXUS_SESSION_DIAGNOSTIC_V1 login-proof-{shape}");
++ return match response.requested_security_token.binary_security_token {
++ Some(token) if !token.value.is_empty() => Ok((account, token.value)),
++ _ => Err("msa-ticket"),
++ };
++ }
++ let compact = matches!(kind.as_str(), "urn:passport:compact" | "urn:passport:delegationcompact")
++ && response.requested_security_token.binary_security_token.is_some();
++ if !compact {
++ let slug: String = kind.chars().map(|c| if c.is_ascii_alphabetic() { c.to_ascii_lowercase() } else { '-' }).take(40).collect();
++ eprintln!("NEXUS_SESSION_DIAGNOSTIC_V1 ticket-type-{slug}");
++ return Err("msa-ticket");
++ }
++ let Token::Compact(ticket) = Token::from(response) else { return Err("msa-ticket"); };
++ Ok((account, ticket))
++}
++
++async fn xsts(tokens: &TokenManager, client: &reqwest::Client) -> Result<(String, String), &'static str> {
++ let (account, ticket) = ticket(tokens, client, "user.auth.xboxlive.com", soap::PolicyReference::mbi_ssl()).await?;
++ let user = xodus::api::xbox::authenticate_xbox_user(client, ticket).await.map_err(|_| "xbox-user")?;
++ let xsts = xodus::api::xbox::request_xsts_token(client, user.token, GSSV_RELYING_PARTY).await.map_err(|_| "xsts")?;
++ Ok((account, xsts.token))
++}
++
++/// The Microsoft account token a cloud session's /connect step takes (xbox.com asks MSAL for
++/// "service::http://Passport.NET/purpose::PURPOSE_XBOX_CLOUD_CONSOLE_TRANSFER_TOKEN").
++async fn transfer_token(tokens: &TokenManager, client: &reqwest::Client) -> Result {
++ let policy = soap::PolicyReference { uri: "PURPOSE_XBOX_CLOUD_CONSOLE_TRANSFER_TOKEN".into(), val: String::default() };
++ Ok(ticket(tokens, client, "http://Passport.NET/purpose", policy).await?.1)
++}
++
++fn regions(value: &Value) -> Vec {
++ value["offeringSettings"]["regions"].as_array().map(|list| list.iter().filter_map(|region| {
++ let name = region["name"].as_str()?;
++ let base = reqwest::Url::parse(region["baseUri"].as_str()?).ok()?;
++ let host = base.host_str()?;
++ if base.scheme() != "https" || !host.ends_with(".xboxlive.com") || !base.username().is_empty() { return None; }
++ Some(json!({ "name": name, "baseUri": base.as_str().trim_end_matches('/'), "isDefault": region["isDefault"].as_bool().unwrap_or(false) }))
++ }).take(32).collect()).unwrap_or_default()
++}
++
++async fn login(tokens: &TokenManager) -> Result {
++ let client = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none())
++ .timeout(super::session::REQUEST_TIMEOUT).user_agent("xodus-nexus-cloud/1").build().map_err(|_| "http-client")?;
++ let (account, xsts) = xsts(tokens, &client).await?;
++ let mut failures = Vec::new();
++ for offering in OFFERINGS {
++ let response = client.post(format!("https://{offering}.gssv-play-prod.xboxlive.com/v2/login/user"))
++ .header("x-gssv-client", "XboxComBrowser").header("Cache-Control", "no-store, must-revalidate, no-cache")
++ .json(&json!({ "token": xsts, "offeringId": offering })).send().await.map_err(|_| "gssv-request")?;
++ let status = response.status().as_u16();
++ let body = super::profile::bounded(response, 256 * 1024).await;
++ let Ok(body) = body else { failures.push(format!("{offering}:{status}")); continue; };
++ let value: Value = serde_json::from_slice(&body).map_err(|_| "gssv-json")?;
++ let token = value["gsToken"].as_str().filter(|token| !token.is_empty() && token.len() <= 16384).ok_or("gs-token")?;
++ // Diagnostic lines are letters and dashes only (the app's allow-list).
++ eprintln!("NEXUS_SESSION_DIAGNOSTIC_V1 gssv-login-ok-{}", if offering == "xgpuweb" { "gamepass" } else { "free" });
++ let transfer = transfer_token(tokens, &client).await;
++ eprintln!("NEXUS_SESSION_DIAGNOSTIC_V1 transfer-token-{}", if transfer.is_ok() { "ok" } else { "missing" });
++ if tokens.get_user().map_err(|_| "account-changed")?.username != account { return Err("account-changed".into()); }
++ return Ok(json!({ "schema": 1, "account": account, "offering": offering, "gsToken": token, "userToken": transfer.ok(),
++ "durationInSeconds": value["durationInSeconds"].as_u64().unwrap_or(0), "market": value["market"].as_str().unwrap_or_default(),
++ "regions": regions(&value) }));
++ }
++ Err(format!("gssv-login {}", failures.join(" ")))
++}
++
++pub async fn run(tokens: &TokenManager) -> ExitCode {
++ match tokio::time::timeout(Duration::from_secs(45), login(tokens)).await {
++ Ok(Ok(value)) => { println!("{value}"); ExitCode::SUCCESS }
++ Ok(Err(stage)) => { eprintln!("Xbox Cloud Gaming sign-in failed ({stage})."); ExitCode::FAILURE }
++ Err(_) => { eprintln!("Xbox Cloud Gaming sign-in timed out."); ExitCode::FAILURE }
++ }
++}
++
++#[cfg(test)]
++mod tests {
++ use super::*;
++ #[test]
++ fn only_https_xboxlive_regions_are_kept() {
++ let value = json!({"offeringSettings":{"regions":[
++ {"name":"WestEurope","baseUri":"https://weu.core.gssv-play-prod.xboxlive.com/","isDefault":true},
++ {"name":"Evil","baseUri":"https://evil.test/"},
++ {"name":"Plain","baseUri":"http://eus.core.gssv-play-prod.xboxlive.com"}]}});
++ let list = regions(&value);
++ assert_eq!(list.len(), 1);
++ assert_eq!(list[0]["baseUri"], "https://weu.core.gssv-play-prod.xboxlive.com");
++ }
++}
+diff --git a/crates/xodus-cli/src/main.rs b/crates/xodus-cli/src/main.rs
+index 1ae4167..4b0ac26 100644
+--- a/crates/xodus-cli/src/main.rs
++++ b/crates/xodus-cli/src/main.rs
+@@ -68,6 +68,8 @@ enum SubCommand {
+ #[arg(long, default_value_t = false)]
+ json: bool,
+ },
++ #[command(about = "Get an Xbox Cloud Gaming streaming token for the stored account")]
++ CloudToken,
+ #[command(about = "Read Xbox gamertag and gamerpic for the stored account")]
+ Profile {
+ #[arg(long, default_value_t = false)]
+@@ -239,6 +241,7 @@ async fn main() -> ExitCode {
+ args.command,
+ SubCommand::Account { .. }
+ | SubCommand::Profile { .. }
++ | SubCommand::CloudToken
+ | SubCommand::GameActivity { .. }
+ | SubCommand::Achievements { .. }
+ | SubCommand::Library { .. }
+@@ -304,6 +307,7 @@ async fn main() -> ExitCode {
+ }
+ },
+ SubCommand::Profile { json } => commands::profile::run(&tokens, json).await,
++ SubCommand::CloudToken => commands::cloud_token::run(&tokens).await,
+ SubCommand::GameActivity { title_id, json } => commands::game_activity::run(&tokens, title_id, json).await,
+ SubCommand::Achievements { title_id, json } => commands::achievements::run(&tokens, title_id, json).await,
+ SubCommand::Login => commands::login::run(&client, &tokens).await,
diff --git a/tools/xbox/prepare.py b/tools/xbox/prepare.py
index 7a07c6335..47564ad11 100755
--- a/tools/xbox/prepare.py
+++ b/tools/xbox/prepare.py
@@ -10,7 +10,7 @@ recipe = Path(__file__).resolve().parent
cache = Path(sys.argv[1]).resolve()
lock = json.loads((recipe / 'backend.lock.json').read_text())
local_patches = ['droiddeck-credentials.patch', 'droiddeck-launch-view.patch', 'droiddeck-library-subscriptions.patch',
- 'droiddeck-credential-fallback.patch', 'droiddeck-credential-diagnostics.patch']
+ 'droiddeck-credential-fallback.patch', 'droiddeck-credential-diagnostics.patch', 'droiddeck-cloud-token.patch']
identity = hashlib.sha256((recipe / 'backend.lock.json').read_bytes() + b''.join((recipe / name).read_bytes() for name in local_patches)).hexdigest()
source = cache / ('source-' + identity[:16])
if not (source / '.prepared').is_file():
diff --git a/tools/xcloud-player/NOTICE.md b/tools/xcloud-player/NOTICE.md
new file mode 100644
index 000000000..851957ad2
--- /dev/null
+++ b/tools/xcloud-player/NOTICE.md
@@ -0,0 +1,20 @@
+# Xbox Cloud Gaming player
+
+`greenlight/lib` is the WebRTC client of **Greenlight** by **UnknownSKL (Jim Kroon)**
+(https://github.com/unknownskl/greenlight, `packages/player/src/client/lib`, commit ef852c3, MIT โ
+see `greenlight/LICENSE`). DroidDeck changes: `player.ts` renders video with the `