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 `