Xbox Cloud Gaming: stream from the store and Games tab, native player with DroidDeck frame generation (#1531)

Xbox Cloud Gaming: stream from the store and Games tab, native player with DroidDeck frame generation

Stream Xbox games with Xbox Cloud Gaming (Game Pass) from DroidDeck, outside
the Linux session.

- Backend: `xodus-cli cloud-token` (tools/xbox/droiddeck-cloud-token.patch)
  gets the streaming tokens; they stay in memory.
- Web player (default): the Greenlight player page (tools/xcloud-player, MIT)
  in a WebView, plus Microsoft's own xbox.com/play player. In-stream menu with
  screen effects (sharpen, colour, deband, FXAA, CRT, NTSC, Toon, fps cap),
  stream quality, performance overlay (level, layout, top right / top left)
  and rumble.
- Native player (opt-in, Android 10+, Player engine: Native): Google's WebRTC
  (io.github.webrtc-sdk 125.6422.07, BSD) with the Greenlight protocol ported
  to Kotlin, in its own process (:xcloudnative) with its own instance of
  DroidDeck's compositor. Decoded frames reach the compositor as
  AHardwareBuffers (new stream mode, VK_ANDROID_external_memory_android_
  hardware_buffer import), so DDFG 2x and LSFG 2x/3x/4x run on them. Live
  frame generation, frame-rate target (presets and a 10-165 fps slider, even
  pacing), zero-copy picture, small network buffer, fast decoder, instant
  controller input, overlay with engine and on-screen fps, background end,
  restart and web fallback. It takes the main process's tokens through
  XboxCloudTokenService; they are fetched ahead while a cloud game is shown.
- The compositor requests the dma-buf import extensions only where the driver
  has them (the system driver has none).
- Catalog: cloud availability, Game Pass plan CFQ7TTC0K6L8 counted, library
  filter All / Owned / Game Pass / Cloud, Stream and Add to Games on the store
  page, stream-only Games-tab entries (Stream + Download / Play + Stream).
- Xbox games get VC++ 2015-2022 and the ARM64 runtime registry entries
  (vcredist2022_arm64, built in until the catalog has it) before each launch.
- Controller focus reaches pickers opened from a game page (install location,
  uninstall); Add / Remove from Games keep the focus.

Credits: Greenlight by UnknownSKL (Jim Kroon), MIT, player and protocol;
Better xCloud for the resolution / OS mapping; MGamer404's Bannerlator #627
for the ARM64 runtime approach. Ported from the412banner's Bannerlator.
This commit is contained in:
The412Banner authored and GitHub committed 2026-10-24 23:17:57 -04:00
1 parent 5ca1f610ab
commit c5d7cdc058
78 files changed
+7835 -45

No files matched your search

+2
View File
@@ -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'
}
+21
View File
@@ -152,6 +152,27 @@
android:process=":xboxlogin" android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density"
android:theme="@style/AppTheme" />
<activity android:name=".stores.xbox.XboxCloudPlayerActivity" android:exported="false"
android:screenOrientation="sensorLandscape" android:hardwareAccelerated="true"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize|density|navigation|uiMode"
android:theme="@style/AppTheme" />
<service android:name=".stores.xbox.XboxCloudTokenService" android:exported="false" />
<activity android:name=".stores.xbox.XboxNativePlayerActivity" android:exported="false"
android:process=":xcloudnative" android:screenOrientation="sensorLandscape" android:hardwareAccelerated="true"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize|density|navigation|uiMode"
android:theme="@style/AppTheme" />
<activity android:name=".stores.xbox.XboxCloudActivity" android:exported="false"
android:process=":xboxcloud" android:launchMode="singleTop" android:screenOrientation="sensorLandscape"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize|density|navigation|uiMode"
android:theme="@style/AppTheme" />
<activity android:name=".stores.xbox.XboxCloudSettingsActivity" android:exported="false"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density"
android:theme="@style/AppTheme" />
<activity
android:name=".stores.StoreLoginActivity"
android:exported="false"
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<style>
html, body { margin: 0; height: 100%; background: #000; overflow: hidden; }
#player { position: absolute; inset: 0; }
#status { position: absolute; left: 0; right: 0; bottom: 12%; text-align: center; color: #ddd;
font: 16px sans-serif; text-shadow: 0 0 4px #000; pointer-events: none; }
</style>
</head>
<body>
<div id="player"></div>
<div id="status"></div>
<script src="player.js"></script>
</body>
</html>
+187
View File
@@ -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,Line truncated
`+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<i.length;o++)if(i[o].indexOf("m="+t)===0){a=o;break}if(a===-1)return console.debug("Could not find the m line for",t),e;for(a++;i[a].indexOf("i=")===0||i[a].indexOf("c=")===0;)a++;if(i[a].indexOf("b")===0)return i[a]="b=AS:"+n,i.join(`
`);let s=i.slice(0,a);return s.push("b=AS:"+n),s=s.concat(i.slice(a,i.length)),s.join(`
`)}};var P=class{_player;_peerConnection;_videoCodec="";_audioCodec="";_videoWidth=0;_videoHeight=0;_videoFps=0;_rtt=0;_remoteHost="";_remotePort=0;_remoteIsLocal=!1;_remoteIsIpv6=!1;_activeRemoteCandidate="";constructor(e){this._player=e,this._peerConnection=e._peerConnection,setInterval(this.loop.bind(this),1e3)}loop(){this._peerConnection.getStats().then(e=>{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}: <span style="background: ${s[n]}; border-radius: 5px; padding: 4px; padding-left: 8px; padding-right: 8px;">${t}</span>`: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._pLine truncated
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",pointerEvent Line truncated
`:" \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))});})();
@@ -95,4 +95,5 @@ target_link_libraries(droiddeckwayland
dl
log
android
nativewindow
)
@@ -16,6 +16,7 @@
#include <sys/syscall.h>
#include <sys/sysmacros.h>
#include <android/log.h>
#include <android/hardware_buffer.h>
#include <wayland-server.h>
#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;
}
}
+1 -1
View File
@@ -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) \
+72 -7
View File
@@ -19,6 +19,7 @@
#include <time.h>
#include <pthread.h>
#include <android/log.h>
#include <android/hardware_buffer.h>
#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) {
@@ -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);
@@ -9,6 +9,7 @@
#include <sys/resource.h>
#include <android/log.h>
#include <android/native_window_jni.h>
#include <android/hardware_buffer_jni.h>
#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();
}
@@ -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)
@@ -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)
@@ -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<String> = emptyList())
@@ -49,6 +50,17 @@ object WinCompSources {
fun installer(path: String): List<String> = if (UE_PREREQ.containsMatchIn(path)) listOf("vcredist2022", "d3dx9") else DependencyDetector.componentsFor(path)
private val XBOX_KNOWN = Regex("""<KnownDependency\s+Name="([A-Za-z0-9_.-]{1,40})"""")
// VC14 also registers the ARM64 runtime: launchers running as ARM64EC look for it and stop when it is missing.
private val XBOX_COMPONENTS = mapOf("VC14" to listOf("vcredist2022", WinComponents.VCREDIST_ARM64))
/** Microsoft installs a PC Game Pass game's declared runtimes with it; VC14 is wanted by nearly all and is always added. */
fun xbox(folder: File?): List<Pair<String, String>> {
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<String>): List<Pair<String, String>> = 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('/')
@@ -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)
@@ -101,10 +101,28 @@ object WinComponents {
fun cached(context: Context): List<Component>? =
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<Component>? {
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
}
@@ -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<String?>(null)
private fun askForNotifications(context: Context) {
val activity = context as? android.app.Activity ?: return
@@ -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
@@ -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<String, String>): CatalogItem? {
internal fun product(json: JSONObject, access: Map<String, String>, 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<String> {
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<JSONObject> = 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<String>, val all: Set<String>) {
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<CatalogItem>): List<CatalogItem> {
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<String>()
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 }
}
@@ -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: <package>" 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() } }
}
}
}
@@ -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<Entry>()
@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<Entry>): 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<Entry> = 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<String>): List<Library.SteamGame> = 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)
}
@@ -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<String, Int>()
@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)
}
}
}
@@ -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<Region>,
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<String, Map<String, String>>? = null
fun titles(session: Session): Map<String, String> {
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()
}
@@ -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 "中文 (繁體)")
}
}
@@ -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<String>()) }
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)
}
}
}
}
}
}
@@ -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<String>,
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<Int, FocusRequester>()
fun requester(index: Int) = requesters.getOrPut(index) { FocusRequester() }
fun take(): Int = next++.also { count = maxOf(count, next) }
}
private val LocalPanelFocus = compositionLocalOf<PanelFocus?> { null }
@Composable
private fun PanelContent(
settings: XboxCloudSettings,
regions: List<String>,
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 <T> Choice(label: String, options: List<Pair<T, String>>, 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)
}
}
}
@@ -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")
}
}
@@ -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()
}
}
}
}
@@ -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<CatalogItem>, 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)
@@ -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"
}
}
@@ -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 }
}
@@ -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<String>()
@Volatile private var serverName: String? = null
private val candidates = mutableListOf<IceCandidate>()
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<String, Int>()
/** 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<SessionDescription> { done -> peer.createOffer(sdpObserver(done), MediaConstraints()) }
val local = SessionDescription(offer.type, tweakOffer(offer.description))
await<Unit> { 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<Unit> { 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<out IceCandidate>) {}
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<out MediaStream>) { 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 <T> await(start: (done: (Result<T>) -> Unit) -> Unit): T {
val latch = CountDownLatch(1)
var result: Result<T>? = null
start { r -> result = r; latch.countDown() }
check(latch.await(15, TimeUnit.SECONDS)) { "WebRTC timed out" }
return result!!.getOrThrow()
}
@Suppress("UNCHECKED_CAST")
private fun <T> sdpObserver(done: (Result<T>) -> 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<String, String>()
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
}
}
@@ -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() } }
}
}
@@ -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<Image>()
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()
}
}
@@ -75,16 +75,28 @@ internal fun GamesPage(s: FrontEndState, a: FrontEndActions, selected: String, o
val launcherArt by androidx.compose.runtime.produceState<java.io.File?>(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 -> ""
@@ -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) }
@@ -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() }
}
@@ -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<String?>(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<Rect?>(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
@@ -56,7 +56,7 @@ import java.io.File
private fun installable(name: String, all: Map<String, WinComponents.Component>): 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 -> ""
}
@@ -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);
@@ -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<Long, Long> 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(); }
}
}
@@ -56,6 +56,17 @@ class WinCompSourcesTest {
assertEquals(emptyList<String>(), 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("""<Game><DesktopRegistration><DependencyList><KnownDependency Name="VC14"/></DependencyList></DesktopRegistration></Game>""")
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")))
}
@@ -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"),
@@ -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)
}
}
@@ -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"))
}
}
@@ -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)
}
}
+2
View File
@@ -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++)
+6
View File
@@ -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
+171
View File
@@ -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<String, &'static str> {
+ 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> {
+ 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<Value, String> {
+ 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,
+1 -1
View File
@@ -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():
+20
View File
@@ -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 `<video>`
component instead of WebGPU, `render/video.ts` sets a transparent poster, and `input/gamepad.ts` treats missing buttons/axes as unpressed (Android reports no guide button); `uuid` is replaced by `uuid.ts` (crypto.randomUUID).
`sdp-tweaks.ts` (H.264 profile preference, bitrate cap), the CAS / unsharp-mask sharpening in
`renderer.ts` and the "stop quality drops" metadata change follow **Better xCloud** by **redphx**
(https://github.com/redphx/better-xcloud, MIT). The colour, deband, FXAA, Toon, CRT and NTSC shaders are
ported from Bannerlator's screen effects (The412Banner). `streamstats.ts` is DroidDeck's.
`entry.ts` is DroidDeck's: it drives a cloud session through the app (`DDCloud` bridge; the
streaming and Microsoft account tokens never reach the page) and attaches the first controller.
Build: `bash tools/xcloud-player/build.sh` (esbuild) writes
`app/src/main/assets/xcloud/player.js`; the built file is committed.
The page, its sources and the Android side (`stores/xbox/XboxCloud*.kt`) are ported from
Bannerlator's Xbox Cloud Gaming player (https://github.com/The412Banner/Bannerlator).
+9
View File
@@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Bundles the cloud player page script with esbuild 0.24.0 (ESBUILD=/path/to/esbuild, default: esbuild on PATH).
set -euo pipefail
here=$(cd "$(dirname "$0")" && pwd)
repo=$(cd "$here/../.." && pwd)
"${ESBUILD:-esbuild}" "$here/entry.ts" --bundle --format=iife --target=chrome110 --minify \
--alias:uuid="$here/uuid.ts" --legal-comments=inline \
--banner:js="/* DroidDeck Xbox Cloud Gaming player. Includes Greenlight's player (c) UnknownSKL (Jim Kroon), MIT. */" \
--outfile="$repo/app/src/main/assets/xcloud/player.js"
+212
View File
@@ -0,0 +1,212 @@
// DroidDeck cloud player page: runs one Xbox Cloud Gaming session. Every request to Microsoft goes
// through the app (DDCloud), which holds the tokens; this page only does WebRTC, video, sound and input.
import xCloudPlayer from './greenlight/lib/player'
import Gamepad from './greenlight/lib/input/gamepad'
import { EffectRenderer, Effects, needsRenderer } from './renderer'
import { StreamStats } from './streamstats'
import { capVideoBitrate, preferH264Profile } from './sdp-tweaks'
interface Reply { status: number, body: string }
declare const DDCloud: { call(id: number, op: string, arg: string): void, status(text: string): void, failed(stage: string, detail: string): void, playing(): void, log(text: string): void, settings(): string }
/** The player settings the app hands over (XboxCloudSettings.toJson); stream-start ones are applied by the app. */
interface Settings extends Effects {
bitrateMbps: number // 0 = Microsoft's own limit
codec: string // 'default' | 'high' | 'normal' | 'low'
preventDrops: boolean
statsLevel: number // 0 off, 1 fps, 2 basic, 3 detailed, 4 full
statsLayout: string // 'horizontal' | 'vertical'
statsCorner: string // 'right' | 'left'
server: string // the region's host, for the full overlay
volume: number // 100..300 %
rumble: number // 0..100 %
}
let settings: Settings = JSON.parse(DDCloud.settings())
// Codec profile and bitrate cap go into our offer (Better xCloud's approach).
const nativeSetLocalDescription = RTCPeerConnection.prototype.setLocalDescription
RTCPeerConnection.prototype.setLocalDescription = function (this: RTCPeerConnection, description?: RTCLocalSessionDescriptionInit) {
if (description?.sdp) {
if (settings.codec === 'high' || settings.codec === 'normal' || settings.codec === 'low') description.sdp = preferH264Profile(description.sdp, settings.codec)
if (settings.bitrateMbps > 0) description.sdp = capVideoBitrate(description.sdp, settings.bitrateMbps * 1000)
}
return nativeSetLocalDescription.call(this, description as RTCLocalSessionDescriptionInit)
} as typeof RTCPeerConnection.prototype.setLocalDescription
;(window as any).__ddRumble = settings.rumble / 100
// Page errors go to the app's Xbox log (no tokens ever reach this page).
window.addEventListener('error', event => DDCloud.log(`error: ${event.message} @${event.lineno}`))
window.addEventListener('unhandledrejection', event => DDCloud.log(`rejection: ${String(event.reason).slice(0, 200)}`))
window.addEventListener('gamepadconnected', event => DDCloud.log(`controller: ${event.gamepad.id.slice(0, 60)} mapping=${event.gamepad.mapping || 'none'} buttons=${event.gamepad.buttons.length} axes=${event.gamepad.axes.length}`))
const pending = new Map<number, (reply: Reply) => void>()
let nextId = 1
;(window as any).__ddResolve = (id: number, status: number, body: string) => {
const done = pending.get(id)
pending.delete(id)
if (done) done({ status, body })
}
function call(op: string, arg: unknown = ''): Promise<Reply> {
return new Promise(resolve => {
const id = nextId++
pending.set(id, resolve)
DDCloud.call(id, op, typeof arg === 'string' ? arg : JSON.stringify(arg))
})
}
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
const ok = (reply: Reply) => reply.status >= 200 && reply.status < 300
function show(text: string) {
const element = document.getElementById('status')
if (element) element.textContent = text
DDCloud.status(text)
}
class Failure extends Error {
constructor(public stage: string, public detail: string) { super(stage) }
}
function expect(stage: string, reply: Reply): Reply {
if (!ok(reply)) throw new Failure(stage, `${reply.status} ${reply.body.slice(0, 300)}`)
return reply
}
function waitForCandidates(pc: RTCPeerConnection, timeout: number): Promise<void> {
return new Promise(resolve => {
if (pc.iceGatheringState === 'complete') return resolve()
const timer = setTimeout(resolve, timeout)
pc.addEventListener('icegatheringstatechange', () => {
if (pc.iceGatheringState === 'complete') { clearTimeout(timer); resolve() }
})
})
}
async function run() {
show('Starting the game on Xbox Cloud Gaming…')
expect('start', await call('start'))
let connected = false
let waited = 0
for (;;) {
const reply = expect('state', await call('state'))
const state = JSON.parse(reply.body || '{}')
if (state.state === 'Provisioned') break
if (state.state === 'Failed') throw new Failure('provision', JSON.stringify(state.errorDetails ?? {}).slice(0, 300))
if (state.state === 'ReadyToConnect' && !connected) {
connected = true
show('Signing in to the cloud console…')
expect('connect', await call('connect'))
} else if (state.state === 'WaitingForResources') {
show(`In the queue for a cloud console… ${Math.round(waited / 1000)} s`)
} else {
show(`Getting a cloud console ready… ${Math.round(waited / 1000)} s`)
}
await sleep(1000)
waited += 1000
}
show('Connecting…')
const player = new xCloudPlayer('player')
player.init()
const pc = player._peerConnection
// "Stop quality drops": report a short decode time so the server does not lower the resolution
// for a slow decoder (Better xCloud's patchStreamMetadata).
const input = player._channels.input as any
const queueMetadata = input.queueMetadataFrame.bind(input)
input.queueMetadataFrame = (frame: any) => {
if (settings.preventDrops) {
const render = frame.frameRenderedTimeMs - frame.frameDecodedTimeMs
frame.frameDecodedTimeMs = frame.frameSubmittedTimeMs + 10
frame.frameRenderedTimeMs = frame.frameDecodedTimeMs + render
}
queueMetadata(frame)
}
// Volume above 100 %: the stream's sound goes through a gain node instead of the <audio> element.
let gain: GainNode | undefined
let audioStream: MediaStream | undefined
const applyVolume = () => {
if (audioStream === undefined || (gain === undefined && settings.volume === 100)) return
if (gain === undefined) {
const context = new AudioContext()
gain = context.createGain()
context.createMediaStreamSource(audioStream).connect(gain).connect(context.destination)
const element = document.querySelector('#player audio') as HTMLAudioElement | null
if (element) element.muted = true
}
gain.gain.value = settings.volume / 100
}
pc.addEventListener('track', event => {
if (event.track.kind === 'audio') { audioStream = event.streams[0]; applyVolume() }
})
const stats = new StreamStats(pc)
let renderer: EffectRenderer | undefined
const applyEffects = () => {
const video = player.getVideoElement() as HTMLVideoElement | undefined
if (video === undefined) return
if (!needsRenderer(settings)) { renderer?.pause(); return }
if (renderer === undefined) {
try { renderer = new EffectRenderer(video, settings) }
catch (error) { DDCloud.log(`effects unavailable: ${String(error).slice(0, 200)}`) }
} else renderer.resume(settings)
}
// Live changes from the in-player menu.
;(window as any).__ddApply = (json: string) => {
settings = JSON.parse(json)
;(window as any).__ddRumble = settings.rumble / 100
stats.show(settings.statsLevel, settings.statsLayout === 'vertical', settings.server, settings.statsCorner === 'left')
applyVolume()
applyEffects()
}
pc.addEventListener('connectionstatechange', () => {
if (pc.connectionState === 'connected') {
show(''); DDCloud.playing()
stats.show(settings.statsLevel, settings.statsLayout === 'vertical', settings.server, settings.statsCorner === 'left')
// The video element exists once its track arrived; give it a moment for its first frame.
setTimeout(applyEffects, 500)
}
else if (pc.connectionState === 'failed') DDCloud.failed('webrtc', 'connection failed')
else if (pc.connectionState === 'disconnected') show('Connection lost…')
})
const offer = await player.createOffer()
const sdp = expect('sdp', await call('sdp', offer.sdp ?? ''))
const answer = JSON.parse(JSON.parse(sdp.body).exchangeResponse)
if (answer.status && answer.status !== 'success') throw new Failure('sdp-answer', JSON.stringify(answer).slice(0, 300))
await player.setRemoteSDP(answer.sdp)
await waitForCandidates(pc, 3000)
const local = (await player.getICECandidates()).map(c => JSON.stringify({
candidate: c.candidate, sdpMid: c.sdpMid, sdpMLineIndex: c.sdpMLineIndex, usernameFragment: c.usernameFragment,
}))
const ice = expect('ice', await call('ice', local))
player.setRemoteIceCandidates(JSON.parse(JSON.parse(ice.body).exchangeResponse))
// The control channel's authorization (sent on the message handshake) announces controller 0 and
// then removes it, clearing any handler attached before; attach the controller after that.
const handshake = Date.now()
while (!player._channels.message._handshakeCompleted && Date.now() - handshake < 20_000) await sleep(100)
DDCloud.log(`handshake ${player._channels.message._handshakeCompleted ? 'done' : 'missing'} after ${Date.now() - handshake} ms`)
await sleep(300)
const pad = new Gamepad(0, { enable_keyboard: false })
pad.attach(player)
DDCloud.log(`controllers at attach: ${Array.from(navigator.getGamepads()).filter(Boolean).length}`)
// The app's "Xbox button" (in the leave dialog) presses Nexus for a moment.
;(window as any).__ddNexus = () => { pad.sendButtonState('Nexus', 1); setTimeout(() => pad.sendButtonState('Nexus', 0), 200) }
// The app's menu button / a controller's Guide button: Nexus follows the physical button.
;(window as any).__ddButton = (name: string, value: number) => pad.sendButtonState(name, value)
// The in-player menu pauses controller input to the game while it is open.
;(window as any).__ddInput = (enabled: boolean) => { if (enabled) pad.onWindowFocus(); else pad.onWindowBlur() }
setInterval(() => { call('keepalive') }, 30_000)
;(window as any).__ddStop = () => { try { renderer?.destroy(); stats.destroy(); pad.detach(); player.destroy() } catch (e) { /* closing anyway */ } }
}
run().catch((error: unknown) => {
if (error instanceof Failure) DDCloud.failed(error.stage, error.detail)
else DDCloud.failed('page', String(error).slice(0, 300))
})
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2019 UnknownSKL (Jim Kroon)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,69 @@
import xCloudPlayer from '../player'
export default class Channel {
private _player:xCloudPlayer
private _dataChannel:RTCDataChannel
constructor(player:xCloudPlayer) {
this._player = player
// console.log('creating data channel: ', this.constructor.name, this, this.getChannelConfig())
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)
}
// Channel config functions
getChannelName() {
return 'channel'
}
getChannelConfig() {
return { }
}
// Default channel functions
onOpen(event:Event) {
console.log(this.getChannelName(), 'Opening channel:', event)
}
onMessage(event:MessageEvent) {
console.log(this.getChannelName(), 'Message channel:', event)
}
onClosing(event:Event) {
console.log(this.getChannelName(), 'Closing channel:', event)
}
onClose(event:Event) {
console.log(this.getChannelName(), 'Closed channel:', event)
}
onError(event:Event) {
console.log(this.getChannelName(), 'Error channel:', event)
}
send(data:any) {
if(this._dataChannel.readyState !== 'open'){
console.log('['+this.getChannelName()+'] Tried to send data while channel is not open. readyState is:', this._dataChannel.readyState)
return
}
if(typeof data === 'string'){
data = (new TextEncoder).encode(data)
}
return this._dataChannel.send(data)
}
getPlayer(){
return this._player
}
// Channel destroy function
destroy() {}
}
@@ -0,0 +1,84 @@
import Channel from './channel'
export default class ChatChannel extends Channel {
_micPermissions:'granted'|'denied'|'prompt' = 'prompt'
_micStream:MediaStream | undefined
constructor(player:any){
super(player)
navigator.permissions.query({ name: 'microphone' }).then((permissionStatus) => {
this._micPermissions = permissionStatus.state
permissionStatus.onchange = () => {
this._micPermissions = permissionStatus.state
}
})
}
getChannelName() {
return 'chat'
}
getChannelConfig() {
return {
ordered: true,
protocol: 'chatV1',
}
}
// startMicrophone() {
// if(this._micPermissions === 'denied'){
// alert('Microphone permissions are denied. Please enable them in your browser settings.')
// return
// }
// if(this._micStream !== undefined){
// console.log('Microphone already started. You need to stop it first by calling stopMicrophone()')
// return
// }
// navigator.mediaDevices.getUserMedia({ audio: {
// channelCount: 1,
// sampleRate: 24e3,
// } }).then((stream) => {
// if(this._micStream !== undefined){
// return
// }
// this._micStream = stream
// // Add to SDP
// this.getPlayer()._peerConnection.addTrack(stream.getAudioTracks()[0], stream)
// this.getPlayer().createOffer().then((offer) => {
// if(this.getPlayer()._sdpHandler){
// this.getPlayer()._sdpHandler(offer)
// } else {
// console.log('No SDP handler set. Set an SDP Handler via player.setChatSdpHandler()')
// }
// })
// }).catch((err) => {
// alert(`Error connecting to microphone: ${err}`)
// })
// }
// stopMicrophone() {
// if(this._micStream === undefined) {return}
// this._micStream.getTracks().forEach((track) => {
// track.stop()
// })
// this.getPlayer()._peerConnection.getSenders().forEach((sender) => {
// if(sender.track && sender.track.kind === 'audio'){
// this.getPlayer()._peerConnection.removeTrack(sender)
// }
// })
// this._micStream = undefined
// }
destroy() {
// console.log('DebugChannel destroy() called')
}
}
@@ -0,0 +1,108 @@
import Gamepad from '../input/gamepad'
import MouseKeyboard from '../input/mousekeyboard'
import Touch from '../input/touch'
import Channel from './channel'
interface GamepadHandlers {
0: undefined|Gamepad|MouseKeyboard|Touch;
1: undefined|Gamepad|MouseKeyboard|Touch;
2: undefined|Gamepad|MouseKeyboard|Touch;
3: undefined|Gamepad|MouseKeyboard|Touch;
}
export default class ControlChannel extends Channel {
_keyframeInterval
_gamepadHandlers:GamepadHandlers = {
0: undefined,
1: undefined,
2: undefined,
3: undefined,
}
getChannelName() {
return 'control'
}
getChannelConfig() {
return {
protocol: 'controlV1',
ordered: true,
}
}
destroy() {
// console.log('DebugChannel destroy() called')
clearInterval(this._keyframeInterval)
this._gamepadHandlers[0]?.detach()
this._gamepadHandlers[1]?.detach()
this._gamepadHandlers[2]?.detach()
this._gamepadHandlers[3]?.detach()
}
sendAuthorization(){
const authRequest = JSON.stringify({
'message':'authorizationRequest',
'accessKey':'4BDB3609-C1F1-4195-9B37-FEFF45DA8B8E',
})
this.send(authRequest)
this.sendGamepadState(0, true)
this.sendGamepadState(0, false)
// if(this.getPlayer()._config.keyframe_interval > 0) {
// this._keyframeInterval = setInterval(() => {
// this.requestKeyframeRequest(true)
// }, this.getPlayer()._config.keyframe_interval*1000)
// }
}
sendGamepadState(gamepadIndex, wasAdded = true, handler:undefined|Gamepad|MouseKeyboard|Touch = undefined) {
if(wasAdded === true){
if(handler !== undefined){
if(this._gamepadHandlers[gamepadIndex] !== undefined){
this._gamepadHandlers[gamepadIndex]?.detach()
}
this._gamepadHandlers[gamepadIndex] = handler
}
} else {
this._gamepadHandlers[gamepadIndex] = undefined
}
const gamepadRequest = JSON.stringify({
'message': 'gamepadChanged',
'gamepadIndex': gamepadIndex,
'wasAdded': wasAdded,
})
this.send(gamepadRequest)
}
requestKeyframeRequest(ifrRequested = false) {
// console.log('xCloudPlayer Channel/Control.ts - ['+this._channelName+'] User requested Video KeyFrame')
const keyframeRequest = JSON.stringify({
message: 'videoKeyframeRequested',
ifrRequested: ifrRequested,
})
this.send(keyframeRequest)
}
getGamepadHandlers(){
return this._gamepadHandlers
}
getGamepadHandler(index):Gamepad|undefined{
for(const gamepad in this.getPlayer()._channels.control.getGamepadHandlers()){
if(
this.getPlayer()._channels.control.getGamepadHandlers()[gamepad] instanceof Gamepad &&
(this.getPlayer()._channels.control.getGamepadHandlers()[gamepad] as Gamepad).getPhysicalGamepadId() === index
) {
return this.getPlayer()._channels.control.getGamepadHandlers()[gamepad]
}
}
return undefined
}
}
@@ -0,0 +1,15 @@
import Channel from './channel'
export default class DebugChannel extends Channel {
getChannelName() {
return 'debug'
}
getChannelConfig() {
return { }
}
destroy() {
// console.log('DebugChannel destroy() called')
}
}
@@ -0,0 +1,142 @@
import Channel from './channel'
import InputPacket, { MetadataFrame, ReportTypes, InputFrame as GamepadFrame, MouseFrame, KeyboardFrame, PointerFrame } from './input/packet'
import InputQueue from './input/queue'
import Gamepad from '../input/gamepad'
export interface VibrationFrame {
gamepadIndex: number;
leftMotorPercent: number;
rightMotorPercent: number;
leftTriggerMotorPercent: number;
rightTriggerMotorPercent: number;
durationMs: number;
delayMs: number;
repeat: number;
}
export default class InputChannel extends Channel {
private _serverVideoWidth = 0
private _serverVideoHeight = 0
private _inputQueue = new InputQueue(this.getPlayer())
getChannelName() {
return 'input'
}
getChannelConfig() {
return {
ordered: true,
protocol: '1.0',
}
}
getServerVideoWidth(){
return this._serverVideoWidth
}
getServerVideoHeight(){
return this._serverVideoHeight
}
start(){
const Packet = new InputPacket(0)
Packet.setMetadata(navigator.maxTouchPoints > 1 ? navigator.maxTouchPoints : 1)
this.send(Packet.toBuffer())
console.log('[InputChannel] Sent metadata:', Packet, Packet.toBuffer())
setTimeout(this.gamepadStateLoop.bind(this), 16)
}
onMessage(event: MessageEvent<any>) {
const dataView = new DataView(event.data)
const reportType = dataView.getUint8(0)
console.log('[InputChannel] received:', dataView)
if(reportType === ReportTypes.Vibration){
console.log('Received a vibration report')
dataView.getUint8(2) // rumbleType: 0 = FourMotorRumble
const gamepadIndex = dataView.getUint8(3)
const gamepad = this.getPlayer()._channels.control.getGamepadHandler(gamepadIndex)
if(gamepad !== undefined){
const leftMotorPercent = dataView.getUint8(4) / 100
const rightMotorPercent = dataView.getUint8(5) / 100
const leftTriggerMotorPercent = dataView.getUint8(6) / 100
const rightTriggerMotorPercent = dataView.getUint8(7) / 100
const durationMs = dataView.getUint16(8, true)
const delayMs = dataView.getUint16(10, true)
const repeat = dataView.getUint8(12)
const rumble:VibrationFrame = {
gamepadIndex,
leftMotorPercent,
rightMotorPercent,
leftTriggerMotorPercent,
rightTriggerMotorPercent,
durationMs,
delayMs,
repeat,
}
gamepad.handleVibration(rumble)
} else {
console.log('[InputChannel] Received a vibration report but no gamepad handler is available')
}
} else if(reportType === ReportTypes.ServerMetadata){
console.log('[InputChannel] Received server video dimensions:', dataView.getUint32(2, true), dataView.getUint32(6, true))
this._serverVideoHeight = dataView.getUint32(2, true)
this._serverVideoWidth = dataView.getUint32(6, true)
}
}
gamepadStateLoop(){
const gamepadHandlers = this.getPlayer()._channels.control.getGamepadHandlers()
// console.log('[InputChannel] gamepadStateLoop() called', gamepadHandlers)
const gamepadFrames:Array<GamepadFrame> = []
for(const gamepad in gamepadHandlers){
if(gamepadHandlers[gamepad] instanceof Gamepad){
const frame = (gamepadHandlers[gamepad] as Gamepad).getGamepadState()
if(frame !== undefined){
gamepadFrames.push(frame)
}
}
}
if(gamepadFrames.length > 0){
this.queueGamepadFrames(gamepadFrames)
}
setTimeout(this.gamepadStateLoop.bind(this), 16)
}
queueMetadataFrame(data:MetadataFrame){
return this._inputQueue.queueMetadataFrame(data)
}
queueGamepadFrames(frames:Array<GamepadFrame>, forceSend = false){
this._inputQueue.queueGamepadFrames(frames, forceSend)
}
queueMouseFrame(data:MouseFrame){
this._inputQueue.queueMouseFrame(data)
}
queueKeyboardFrame(data:KeyboardFrame){
this._inputQueue.queueKeyboardFrame(data)
}
queuePointerFrame(data:PointerFrame){
this._inputQueue.queuePointerFrame(data)
}
destroy() {
// console.log('DebugChannel destroy() called')
}
}
@@ -0,0 +1,404 @@
// import { InputFrame, PointerFrame, MouseFrame, KeyboardFrame } from '../Input'
export interface InputFrame {
GamepadIndex: number;
Nexus: number;
Menu: number;
View: number;
A: number;
B: number;
X: number;
Y: number;
DPadUp: number;
DPadDown: number;
DPadLeft: number;
DPadRight: number;
LeftShoulder: number;
RightShoulder: number;
LeftThumb: number;
RightThumb: number;
LeftThumbXAxis: number;
LeftThumbYAxis: number;
RightThumbXAxis: number;
RightThumbYAxis: number;
LeftTrigger: number;
RightTrigger: number;
}
export interface PointerFrame {
events: Array<any>;
}
export interface MouseFrame {
X: number;
Y: number;
WheelX: number;
WheelY: number;
Buttons: number;
Relative: number;
}
export interface KeyboardFrame {
pressed: boolean;
keyCode: number;
key: string;
}
export enum ReportTypes {
None = 0,
Metadata = 1,
Gamepad = 2,
Pointer = 4,
ClientMetadata = 8,
ServerMetadata = 16,
Mouse = 32,
Keyboard = 64,
Vibration = 128,
Sensor = 256,
UnreliableInputReport = 512,
UnreliableInputAck = 1024,
}
export interface MetadataFrame {
serverDataKey: number;
firstFramePacketArrivalTimeMs: number;
frameSubmittedTimeMs: number;
frameDecodedTimeMs: number;
frameRenderedTimeMs: number;
}
export interface StreamConfig {
width: number;
height: number;
}
export default class InputPacket {
_reportType = ReportTypes.None
_totalSize = -1
_sequence = -1
_metadataFrames:Array<MetadataFrame> = []
_gamepadFrames:Array<InputFrame> = []
_pointerFrames:Array<PointerFrame> = []
_mouseFrames:Array<MouseFrame> = []
_keyboardFrames:Array<KeyboardFrame> = []
_maxTouchpoints = 0
_serverVideoWidth = 0
_serverVideoHeight = 0
constructor(sequence:number){
this._sequence = sequence
}
setStreamConfig(options:StreamConfig){
this._serverVideoWidth = options.width
this._serverVideoHeight = options.height
}
setMetadata(maxTouchpoints = 1){
this._reportType = ReportTypes.ClientMetadata
this._totalSize = 15
this._maxTouchpoints = maxTouchpoints
}
setData(metadataQueue:Array<any>, gamepadQueue:Array<InputFrame>, pointerQueue:Array<PointerFrame>, mouseQueue:Array<MouseFrame>, keyboardQueue:Array<KeyboardFrame>){
let size = 14
if(metadataQueue.length > 0){
this._reportType |= ReportTypes.Metadata
size = size+this._calculateMetadataSize(metadataQueue)
this._metadataFrames = metadataQueue
}
if(gamepadQueue.length > 0){
this._reportType |= ReportTypes.Gamepad
size = size+this._calculateGamepadSize(gamepadQueue)
this._gamepadFrames = gamepadQueue
}
if(pointerQueue.length > 0){
this._reportType |= ReportTypes.Pointer
size = size+this._calculatePointerSize(pointerQueue)
this._pointerFrames = pointerQueue
}
if(mouseQueue.length > 0){
this._reportType |= ReportTypes.Mouse
size = size+this._calculateMouseSize(mouseQueue)
this._mouseFrames = mouseQueue
}
if(keyboardQueue.length > 0){
this._reportType |= ReportTypes.Keyboard
size = size+this._calculateKeyboardSize(keyboardQueue)
this._keyboardFrames = keyboardQueue
}
this._totalSize = size
}
_calculateMetadataSize(frames:Array<MetadataFrame>){
return 1 + ((7 * 4) * frames.length)
}
_calculateGamepadSize(frames:Array<InputFrame>){
return 1 + (23 * frames.length)
}
_calculatePointerSize(frames:Array<PointerFrame>){
let pointerSize = 1
for(const frame in frames){
pointerSize = pointerSize + 1 + (frames[frame].events.length * 20)
}
return pointerSize
}
_calculateMouseSize(frames:Array<MouseFrame>){
return 1 + (18 * frames.length)
}
_calculateKeyboardSize(frames:Array<KeyboardFrame>){
return 1 + (3 * frames.length)
}
_writeMetadataData(packet:DataView, offset:number, frames:Array<MetadataFrame>){
packet.setUint8(offset, frames.length)
offset++
if(frames.length >= 30){
console.warn('metadataQueue is bigger then 30. This might impact reliability!')
}
for(let i = 0; i < frames.length; i++) {
const frame = frames[i]
const firstFramePacketArrivalTimeMs = frame.firstFramePacketArrivalTimeMs
const frameSubmittedTimeMs = frame.frameSubmittedTimeMs
const frameDecodedTimeMs = frame.frameDecodedTimeMs
const frameRenderedTimeMs = frame.frameRenderedTimeMs
const framePacketTime = performance.now()
const frameDateNow = performance.now()
packet.setUint32(offset, frame.serverDataKey, true)
packet.setUint32(offset+4, firstFramePacketArrivalTimeMs, true)
packet.setUint32(offset+8, frameSubmittedTimeMs, true)
packet.setUint32(offset+12, frameDecodedTimeMs, true)
packet.setUint32(offset+16, frameRenderedTimeMs, true)
packet.setUint32(offset+20, framePacketTime, true)
packet.setUint32(offset+24, frameDateNow, true)
offset += 28
}
return offset
}
_writeGamepadData(packet:DataView, offset:number, frames:Array<InputFrame>){
packet.setUint8(offset, frames.length)
offset++
if(frames.length >= 30){
console.warn('gamepadQueue is bigger then 30. This might impact reliability!')
}
for(let i = 0; i < frames.length; i++) {
const input:InputFrame = frames[i]
packet.setUint8(offset, input.GamepadIndex)
offset++
let buttonMask = 0
if(input.Nexus > 0){ buttonMask |= 2 }
if(input.Menu > 0){ buttonMask |= 4 }
if(input.View > 0){ buttonMask |= 8 }
if(input.A > 0){ buttonMask |= 16 }
if(input.B > 0){ buttonMask |= 32 }
if(input.X > 0){ buttonMask |= 64 }
if(input.Y > 0){ buttonMask |= 128 }
if(input.DPadUp > 0){ buttonMask |= 256 }
if(input.DPadDown > 0){ buttonMask |= 512 }
if(input.DPadLeft > 0){ buttonMask |= 1024 }
if(input.DPadRight > 0){ buttonMask |= 2048 }
if(input.LeftShoulder > 0){ buttonMask |= 4096 }
if(input.RightShoulder > 0){ buttonMask |= 8192 }
if(input.LeftThumb > 0){ buttonMask |= 16384 }
if(input.RightThumb > 0){ buttonMask |= 32768 }
packet.setUint16(offset, buttonMask, true)
packet.setInt16(offset+2, this._normalizeAxisValue(input.LeftThumbXAxis), true) // LeftThumbXAxis
packet.setInt16(offset+4, this._normalizeAxisValue(-input.LeftThumbYAxis), true) // LeftThumbYAxis
packet.setInt16(offset+6, this._normalizeAxisValue(input.RightThumbXAxis), true) // RightThumbXAxis
packet.setInt16(offset+8, this._normalizeAxisValue(-input.RightThumbYAxis), true) // RightThumbYAxis
packet.setUint16(offset+10, this._normalizeTriggerValue(input.LeftTrigger), true) // LeftTrigger
packet.setUint16(offset+12, this._normalizeTriggerValue(input.RightTrigger), true) // RightTrigger
packet.setUint32(offset+14, 1, true) // PhysicalPhysicality
packet.setUint32(offset+18, 1, false) // VirtualPhysicality
offset += 22
}
return offset
}
_writePointerData(packet:DataView, offset:number, frames:Array<PointerFrame>){
packet.setUint8(offset, 1)
// packet.setUint8(offset, frames.length)
offset++
if(frames.length >= 2){
console.warn('pointerQueue is bigger then 1. Only one event will be sent.')
}
// for (; frames.length > 0;) {
// this._inputFps.count()
const frame = frames[0]
if(frame !== undefined){
packet.setUint8(offset, frame.events.length)
offset++
const screenWidth = this._serverVideoWidth
const screenHeight = this._serverVideoHeight
for(let i = 0; i < frame.events.length; i++){
const event = frame.events[i]
const rect = event.target.getBoundingClientRect()
let e = 0.06575749909301447 * (screenHeight / 1)
let n = 0.06575749909301447 * (screenWidth / 1)
// @TODO: Check if this is correct
e =1
n =1
if(event.type === 'pointerup'){
e = 0
n = 0
}
packet.setUint16(offset, e, true)
packet.setUint16(offset+2, n, true)
packet.setUint8(offset+4, 255*event.pressure)
packet.setUint16(offset+5, event.twist, true)
packet.setUint32(offset+7, 0, true)
let o = (event.x - rect.left) * (screenWidth / rect.width)
, l = (event.y - rect.top) * (screenHeight / rect.height)
if(event.type === 'pointerup'){
// Reset x and y to 0 on pointerup
o = 0
l = 0
}
packet.setUint32(offset+11, o, true)
packet.setUint32(offset+15, l, true)
packet.setUint8(offset+19, (event.type === 'pointerdown') ? 1 :
(event.type === 'pointerup') ? 2 : (event.type === 'pointermove') ? 3 : 0)
offset = offset+20
}
}
return offset
}
_writeMouseData(packet:DataView, offset:number, frames:Array<MouseFrame>){
packet.setUint8(offset, frames.length)
offset++
if(frames.length >= 30){
console.warn('mouseQueue is bigger then 30. This might impact reliability!')
}
for(let i = 0; i < frames.length; i++) {
const input:MouseFrame = frames[i]
packet.setUint32(offset, input.X, true)
packet.setUint32(offset+4, input.Y, true)
packet.setUint32(offset+8, input.WheelX, true)
packet.setUint32(offset+12, input.WheelY, true)
packet.setUint8(offset+16, input.Buttons)
packet.setUint8(offset+17, input.Relative)
offset += 18
}
return offset
}
_writeKeyboardData(packet:DataView, offset:number, frames:Array<KeyboardFrame>){
packet.setUint8(offset, frames.length)
offset++
if(frames.length >= 30){
console.warn('keyboardQueue is bigger then 30. This might impact reliability!')
}
for(let i = 0; i < frames.length; i++) {
const input:KeyboardFrame = frames[i]
packet.setUint8(offset, 2) // 1 = Known, 2 = VKey, 3 = AppCommand, 0 = Unknown
packet.setUint8(offset+1, input.pressed ? 1 : 0)
packet.setUint8(offset+2, input.keyCode)
// packet.setUint8(offset+3, 1)
// packet.setUint8(offset+4, input.keyCode)
offset += 3
}
return offset
}
toBuffer(){
const metadataAlloc = new Uint8Array(this._totalSize)
const packet = new DataView(metadataAlloc.buffer)
packet.setUint16(0, this._reportType, true)
packet.setUint32(2, this._sequence, true)
packet.setFloat64(6, performance.now(), true)
let offset = 14
if(this._metadataFrames.length > 0) {offset = this._writeMetadataData(packet, offset, this._metadataFrames)}
if(this._gamepadFrames.length > 0) {offset = this._writeGamepadData(packet, offset, this._gamepadFrames)}
if(this._pointerFrames.length > 0) {offset = this._writePointerData(packet, offset, this._pointerFrames)}
if(this._mouseFrames.length > 0) {offset = this._writeMouseData(packet, offset, this._mouseFrames)}
if(this._keyboardFrames.length > 0) {offset = this._writeKeyboardData(packet, offset, this._keyboardFrames)}
if(this._reportType === ReportTypes.ClientMetadata){
packet.setUint8(offset, this._maxTouchpoints)
offset++
}
if(offset !== offset){
throw new Error('Packet length mismatch. Something is wrong!')
}
return packet
}
_normalizeTriggerValue(value: number): number {
if (value <= 0 || Number.isNaN(value)) {
return 0
}
if (value >= 1) {
return 65535
}
// Fast float -> int conversion in range [0, 65535]
return (value * 65535) | 0
}
_normalizeAxisValue(value: number) {
const max = 32767
const min = -32767
const scaled = Math.round(value * max)
return scaled > max ? max : scaled < min ? min : scaled
}
}
@@ -0,0 +1,171 @@
import xCloudPlayer from '../../player'
import InputPacket, { InputFrame as GamepadFrame, KeyboardFrame, MetadataFrame, MouseFrame, PointerFrame } from './packet'
export default class InputQueue {
private _player:xCloudPlayer
private _inputSequenceNum = 0
private _metadataQueue:Array<MetadataFrame> = []
private _gamepadQueue:Array<GamepadFrame> = []
private _pointerQueue:Array<PointerFrame> = []
private _mouseQueue:Array<MouseFrame> = []
private _keyboardQueue:Array<KeyboardFrame> = []
// Axis change detection and heartbeat tracking
private _gamepadHeartbeatTimers = new Map<number, number>()
private _lastGamepadState = new Map<number, GamepadFrame>()
private _axisChangeThreshold = 0.08
constructor(player:xCloudPlayer) {
this._player = player
}
private _hasQueuedData() {
return this._metadataQueue.length > 0 ||
this._gamepadQueue.length > 0 ||
this._pointerQueue.length > 0 ||
this._mouseQueue.length > 0 ||
this._keyboardQueue.length > 0
}
private _upsertGamepadFrame(data:GamepadFrame) {
for(let i = 0; i < this._gamepadQueue.length; i++){
if(this._gamepadQueue[i].GamepadIndex === data.GamepadIndex){
this._gamepadQueue[i] = data
return
}
}
this._gamepadQueue.push(data)
}
private _hasSignificantAxisChange(current:GamepadFrame, last:GamepadFrame | undefined):boolean {
if(!last) return true // First frame always send
const stickNames = ['LeftThumbXAxis', 'LeftThumbYAxis', 'RightThumbXAxis', 'RightThumbYAxis'] as const
const triggerNames = ['LeftTrigger', 'RightTrigger'] as const
// For analog sticks: only skip if BOTH change is small AND value is near center
for(const stick of stickNames){
const delta = Math.abs(current[stick] - last[stick])
const value = Math.abs(current[stick])
if(delta > this._axisChangeThreshold || value > 0.15){
return true
}
}
// For triggers: always send if they changed (higher sensitivity needed)
for(const trigger of triggerNames){
if(Math.abs(current[trigger] - last[trigger]) > 0.01){
return true
}
}
return false
}
private _isHeartbeatDue(gamepadIndex:number):boolean {
const lastHeartbeat = this._gamepadHeartbeatTimers.get(gamepadIndex) ?? 0
return performance.now() - lastHeartbeat > 33 // Send at least every 33ms
}
queueMetadataFrame(data:MetadataFrame) {
this._metadataQueue.push(data)
this.checkQueueAndSend()
}
queueGamepadFrame(data:GamepadFrame) {
const lastFrame = this._lastGamepadState.get(data.GamepadIndex)
const hasChange = this._hasSignificantAxisChange(data, lastFrame)
const isDue = this._isHeartbeatDue(data.GamepadIndex)
if(hasChange || isDue){
this._upsertGamepadFrame(data)
this._lastGamepadState.set(data.GamepadIndex, data)
this._gamepadHeartbeatTimers.set(data.GamepadIndex, performance.now())
this.checkQueueAndSend()
}
}
queueGamepadFrames(frames:Array<GamepadFrame>, forceSend = false) {
for(const frame of frames){
this._upsertGamepadFrame(frame)
}
this.checkQueueAndSend(forceSend)
}
queueMouseFrame(data:MouseFrame) {
this._mouseQueue.push(data)
this.checkQueueAndSend()
}
queueKeyboardFrame(data:KeyboardFrame) {
this._keyboardQueue.push(data)
this.checkQueueAndSend()
}
queuePointerFrame(data:PointerFrame) {
if(this._pointerQueue.length > 0){
this._pointerQueue[0].events = this._pointerQueue[0].events.concat(data.events)
} else {
this._pointerQueue.push(data)
}
this.checkQueueAndSend()
}
checkQueueAndSend(forceSend = false) {
if(forceSend === true){
if(this._hasQueuedData()){
this.sendQueue()
}
return
}
// Prioritize low-latency gamepad delivery.
if(this._gamepadQueue.length > 0){
this.sendQueue()
return
}
// Non-metadata input should also not wait.
if(this._mouseQueue.length > 0 || this._keyboardQueue.length > 0 || this._pointerQueue.length > 0){
this.sendQueue()
return
}
// Metadata-only can batch slightly.
if(this._metadataQueue.length > 5){
this.sendQueue()
}
}
sendQueue() {
// console.log('Sending queues:', this._metadataQueue.length, this._gamepadQueue.length, this._pointerQueue.length, this._mouseQueue.length, this._keyboardQueue.length)
const packet = new InputPacket(this.getSequenceNum())
if(this._pointerQueue.length > 0){
packet.setStreamConfig({
width: this._player._channels.input.getServerVideoWidth(),
height: this._player._channels.input.getServerVideoHeight(),
})
}
packet.setData(this._metadataQueue, this._gamepadQueue, this._pointerQueue, this._mouseQueue, this._keyboardQueue)
this._player._channels.input.send(packet.toBuffer())
this.clearQueues()
}
clearQueues() {
this._metadataQueue = []
this._gamepadQueue = []
this._pointerQueue = []
this._mouseQueue = []
this._keyboardQueue = []
}
getSequenceNum() {
this._inputSequenceNum++
return this._inputSequenceNum
}
}
@@ -0,0 +1,120 @@
import { v4 as uuidv4 } from 'uuid'
import Channel from './channel'
import MessageHandler from './message/handler'
export default class MessageChannel extends Channel {
_handshakeCompleted = false
getChannelName() {
return 'message'
}
getChannelConfig() {
return {
protocol: 'messageV1',
ordered: true,
}
}
onOpen() {
console.log('[MessageChannel] Sending handshake...')
const handshake = JSON.stringify({
'type':'Handshake',
'version':'messageV1',
'id':'be0bfc6d-1e83-4c8a-90ed-fa8601c5a179',
'cv':'0',
})
this.send(handshake)
}
onMessage(event: MessageEvent<any>) {
const data = JSON.parse(event.data)
// console.log('[MessageChannel] received:', data)
switch(data.type){
case 'HandshakeAck':
console.log('[MessageChannel] Handshake completed')
this._handshakeCompleted = true
// Start control & input channels
this.getPlayer()._channels.control.sendAuthorization()
this.getPlayer()._channels.input.start()
this.sendConfig()
break
case 'TransactionStart':
case 'Message':
new MessageHandler(this, data)
break
default:
console.log('[MessageChannel] Unhandled message type:', data.type, data)
}
}
sendConfig(){
const systemUis = []
const systemVersion = [0, 2, 0]
const uiConfig = JSON.stringify(this.generateMessage('/streaming/systemUi/configuration', {
'version': systemVersion,
'systemUis': systemUis, // Xbox Windows app has [33], xCloud has [10,19,31,27,32,-41]
// 8 = unknown
// 10 = ShowVirtualKeyboard
// 13 = unknown
// 15 = unknown
// 19 = ShowMessageDialog
// 31 = ShowApplication
// 27 = ShowPurchase
// 32 = ShowTimerExtensions
// 33 = Xbox windows app, disables the nexus menu on xCloud (Alt nexus menu?)
// -44 = unknown
// 40 = unknown
// 41 = unknown
// -43 = unknown
// Possible options: Keyboard, PurchaseModal
}))
this.send(uiConfig)
const clientConfig = JSON.stringify(this.generateMessage('/streaming/properties/clientappinstallidchanged', { 'clientAppInstallId': 'c97d7ee0-73b2-4239-bf1d-9d805a338429' }))
this.send(clientConfig)
const orientationConfig = JSON.stringify(this.generateMessage('/streaming/characteristics/orientationchanged', { 'orientation': 0 }))
this.send(orientationConfig)
const touchConfig = JSON.stringify(this.generateMessage('/streaming/characteristics/touchinputenabledchanged', { 'touchInputEnabled': false }))
this.send(touchConfig)
const deviceConfig = JSON.stringify(this.generateMessage('/streaming/characteristics/clientdevicecapabilities', {}))
this.send(deviceConfig)
const dimensionsConfig = JSON.stringify(this.generateMessage('/streaming/characteristics/dimensionschanged', {
'horizontal': 1920,
'vertical': 1080,
'preferredWidth': 1920,
'preferredHeight': 1080,
'safeAreaLeft': 0,
'safeAreaTop': 0,
'safeAreaRight': 1920,
'safeAreaBottom': 1080,
'supportsCustomResolution':true,
}))
this.send(dimensionsConfig)
}
generateMessage(path, data) {
return {
'type': 'Message',
'content': JSON.stringify(data),
'id': uuidv4(),
'target': path,
'cv': '',
}
}
destroy() {
// console.log('DebugChannel destroy() called')
}
}
@@ -0,0 +1,73 @@
import Channel from '../../lib/channel'
export default class MessageHandler {
private _channel:Channel
private _data:object
constructor(channel:Channel, data:object) {
this._channel = channel
this._data = data
this.handle(this._data)
}
handle(data) {
console.log('[MessageHandler] Handling:', data)
switch(data.target){
case '/streaming/sessionLifetimeManagement/serverInitiatedDisconnect':
this.completeTransaction(data.id, '')
this._channel.getPlayer().destroy()
break
case '/streaming/properties/titleinfo':
console.log('Title update:', data)
break
case '/streaming/touchcontrols/showlayoutv2':
// console.log('Showing touch layout:', data)
break
case '/streaming/systemUi/messages/ShowMessageDialog':
this.handleMessage(data)
break
default:
console.log('[MessageChannel] Unhandled transaction:', data)
}
}
completeTransaction(id, data) {
const transaction = JSON.stringify({
'type': 'TransactionComplete',
'content': JSON.stringify(data),
// 'content':'{\'Result\':0}',
'id': id,
'cv': '',
})
this._channel.send(transaction)
}
cancelTransaction(id, data) {
const transaction = JSON.stringify({
'type': 'ReceiverCancel',
'content': JSON.stringify(data),
'id': id,
'cv': '',
})
this._channel.send(transaction)
}
handleMessage(data) {
const jsonData = JSON.parse(data.content)
console.log(jsonData)
if(confirm(jsonData.TitleText+'\n\n'+jsonData.ContentText)){
this.completeTransaction(data.id, { Result: 0 })
} else {
this.completeTransaction(data.id, { Result: 1 })
// this.cancelTransaction(data.id, '')
}
}
}
+56
View File
@@ -0,0 +1,56 @@
import xCloudPlayer from './player'
import Teredo from './teredo'
export default class Ice {
private _peerConnection:RTCPeerConnection
private _iceCandidates: RTCIceCandidate[] = []
constructor(player:xCloudPlayer) {
this._peerConnection = player._peerConnection
this._peerConnection.addEventListener('icecandidate', event => {
if(event.candidate) {
this._iceCandidates.push(event.candidate)
// @TODO: Implement Teredo port selection for remote streaming
}
})
}
getCandidates(){
return this._iceCandidates
}
setRemoteCandidates(candidates:RTCIceCandidate[]){
if(candidates.length === 0){
console.warn('Error: No candidates received!')
}
for(const candidate in candidates){
if(candidates[candidate].candidate === 'a=end-of-candidates') {continue}
const candidateAddress = candidates[candidate].candidate.split(' ')
if(candidateAddress.length > 4 && candidateAddress[4].substr(0, 4) === '2001'){
const teredo = new Teredo(candidateAddress[4])
this._peerConnection.addIceCandidate({
candidate: 'a=candidate:10 1 UDP 1 '+teredo.getIpv4Address()+' 9002 typ host ',
sdpMLineIndex: candidates[candidate].sdpMLineIndex,
sdpMid: candidates[candidate].sdpMid,
})
this._peerConnection.addIceCandidate({
candidate: 'a=candidate:11 1 UDP 1 '+teredo.getIpv4Address()+' '+teredo.getIpv4Port()+' typ host ',
sdpMLineIndex: candidates[candidate].sdpMLineIndex,
sdpMid: candidates[candidate].sdpMid,
})
}
this._peerConnection.addIceCandidate({
candidate: candidates[candidate].candidate,
sdpMid: candidates[candidate].sdpMid,
sdpMLineIndex: candidates[candidate].sdpMLineIndex,
})
}
}
}
@@ -0,0 +1,441 @@
import xCloudPlayer from '../player'
import { VibrationFrame } from '../channel/input'
export interface GamepadOptions {
enable_keyboard?: boolean;
enable_gamepad?: boolean;
enable_vibration?: boolean;
keyboard_mapping?: {
[key:string]: string;
};
gamepad_mapping?: {
[key:string]: string;
};
gamepad_axes_mapping?: {
[key:string]: string;
};
gamepad_deadzone?: number;
gamepad_force_capture?: boolean;
}
export interface GamepadOptionsDefaults extends Required<GamepadOptions> {}
export default class Gamepad {
private _player: xCloudPlayer | undefined
private _index: number
private _physicalGamepadId = -1
private _rumbleInterval
private _isFocused = true
private _gamepadOverride = {
buttons: {},
}
private _options:GamepadOptionsDefaults = {
enable_keyboard: false,
enable_gamepad: true,
enable_vibration: true,
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: 0.08,
gamepad_force_capture: false,
}
private _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(index:number, options:GamepadOptions = {}){
this._index = index
this._options = {
...this._options,
...options,
}
}
attach(xCloudPlayer:xCloudPlayer){
this._player = xCloudPlayer
this._player._channels.control.sendGamepadState(this._index, true, this)
if(this._options.enable_keyboard === true){
window.addEventListener('keydown', this._listener.keyDown)
window.addEventListener('keyup', this._listener.keyUp)
}
if(this._options.enable_gamepad === true){
window.addEventListener('gamepadconnected', this._listener.gamepadConnected)
window.addEventListener('gamepaddisconnected', this._listener.gamepadDisconnected)
}
if(this._options.enable_gamepad === true && this._options.gamepad_force_capture === false){
window.addEventListener('blur', this._listener.windowBlur)
window.addEventListener('focus', this._listener.windowFocus)
}
this.detectActiveGamepad()
}
detach(){
if(this._player === undefined){
console.log('[Gamepad] Player is not attached. this._player is:', this._player)
return
}
this._player._channels.control.sendGamepadState(this._index, false)
this._physicalGamepadId = -1
if(this._rumbleInterval !== undefined){
clearInterval(this._rumbleInterval)
}
if(this._options.enable_keyboard === true){
window.removeEventListener('keydown', this._listener.keyDown)
window.removeEventListener('keyup', this._listener.keyUp)
}
if(this._options.enable_gamepad === true){
window.removeEventListener('gamepadconnected', this._listener.gamepadConnected)
window.removeEventListener('gamepaddisconnected', this._listener.gamepadDisconnected)
}
if(this._options.enable_gamepad === true && this._options.gamepad_force_capture === false){
window.removeEventListener('blur', this._listener.windowBlur)
window.removeEventListener('focus', this._listener.windowFocus)
}
}
sendButtonState(button, value){
if(this._player === undefined){
console.log('[Gamepad] Player is not attached. this._player is:', this._player)
return
}
if(! (button in this.getDefaultFamepadFrame())){
console.log('[Gamepad] Invalid button:', button)
return
}
const buttonPreset = this.getDefaultFamepadFrame()
buttonPreset.GamepadIndex = this._index
this._gamepadOverride.buttons[button] = value
}
onKeyDown(event:KeyboardEvent){
if(this._isFocused === false){
return
}
for(const button in this._options.keyboard_mapping){
if(this._options.keyboard_mapping[button] === event.key){
this._gamepadOverride.buttons[button] = 1
event.preventDefault()
}
}
}
onKeyUp(event:KeyboardEvent){
if(this._isFocused === false){
return
}
for(const button in this._options.keyboard_mapping){
if(this._options.keyboard_mapping[button] === event.key){
this._gamepadOverride.buttons[button] = 0
event.preventDefault()
}
}
}
onWindowFocus(){
this._isFocused = true
}
onWindowBlur(){
// this._isFocused = false
}
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(const gamepadBrowser in navigator.getGamepads()){
const gamepadIndex = navigator.getGamepads()[gamepadBrowser]?.index
if(gamepadIndex === undefined){
continue
}
let gamepadFoundInHandler = false
for(const gamepad in this._player?._channels.control.getGamepadHandlers()){
if(
this._player?._channels.control.getGamepadHandlers()[gamepad] instanceof Gamepad &&
(this._player?._channels.control.getGamepadHandlers()[gamepad] as Gamepad)._physicalGamepadId === navigator.getGamepads()[gamepadBrowser]?.index
) {
console.log('[Gamepad] Active gamepad already detected:', gamepadIndex, (this._player?._channels.control.getGamepadHandlers()[gamepad] as Gamepad)._physicalGamepadId)
gamepadFoundInHandler = true
continue
}
}
if(gamepadFoundInHandler === false){
this._physicalGamepadId = gamepadIndex
console.log('[Gamepad] Active gamepad detected:', this._physicalGamepadId)
}
}
} else {
console.log('[Gamepad] Physical gamepad already detected:', this._physicalGamepadId)
}
}
onGamepadConnected(event:GamepadEvent){
if(this._physicalGamepadId < 0){
for(const gamepad in this._player?._channels.control.getGamepadHandlers()){
if(
this._player?._channels.control.getGamepadHandlers()[gamepad] instanceof Gamepad &&
(this._player?._channels.control.getGamepadHandlers()[gamepad] as Gamepad)._physicalGamepadId === event.gamepad.index
) {
return
}
}
this._physicalGamepadId = event.gamepad.index
}
}
onGamepadDisconnected(event:GamepadEvent){
if(event.gamepad.index === this._physicalGamepadId){
this._physicalGamepadId = -1
}
}
hasOverrideButtons(){
return Object.keys(this._gamepadOverride.buttons).length > 0
}
getGamepadState(){
const gamepad = this.getGamepad(this._physicalGamepadId)
if((this._physicalGamepadId < 0 || gamepad === undefined || this._isFocused === false) && (this.hasOverrideButtons() === false)){
return undefined
}
const frame = this.getDefaultFamepadFrame()
frame.GamepadIndex = this._index
// Set buttons
if(gamepad !== undefined){
for(const button in this._options.gamepad_mapping){
// DroidDeck: Android often reports no guide button (index 16); a missing button is "not pressed".
frame[button] = gamepad.buttons[this._options.gamepad_mapping[button]]?.value ?? 0
}
// Set axis
for(const axis in this._options.gamepad_axes_mapping){
frame[axis] = this.normaliseAxis(gamepad.axes[this._options.gamepad_axes_mapping[axis]] ?? 0)
}
}
// Check for button overrides from the keyboard
// for(const button in this._gamepadOverride.buttons){
// frame[button] = this._gamepadOverride.buttons[button]
// // Clean up existing keys if they are 0
// if(this._gamepadOverride.buttons[button] === 0){
// // Remove key from override
// const newOverrideButtons = {}
// for(const oButton in this._gamepadOverride.buttons){
// if(this._gamepadOverride.buttons[oButton] !== 0){
// newOverrideButtons[oButton] = this._gamepadOverride.buttons[oButton]
// }
// }
// this._gamepadOverride.buttons = newOverrideButtons
// }
// }
// Check for button overrides from the keyboard
for(const button in this._gamepadOverride.buttons){
frame[button] = this._gamepadOverride.buttons[button]
}
// Clean up released buttons in a separate pass
const buttonsToRemove = []
for(const button in this._gamepadOverride.buttons){
if(this._gamepadOverride.buttons[button] === 0){
buttonsToRemove.push(button)
}
}
// Delete released buttons instead of recreating the object
for(const button of buttonsToRemove){
delete this._gamepadOverride.buttons[button]
}
// Start + Select Nexus menu workaround
if(frame.View > 0 && frame.Menu > 0){
frame.View = 0
frame.Menu = 0
frame.Nexus = 1
}
return frame
}
getGamepad(index = -1){
const gamepad = navigator.getGamepads()[index]
if(gamepad !== null) {
return gamepad
} else {
return undefined
}
}
getPhysicalGamepadId(){
return this._physicalGamepadId
}
handleVibration(report:VibrationFrame){
if(this._physicalGamepadId < 0){
console.log('[Gamepad] Received a vibration report but no physical gamepad is connected')
return
}
const gamepad = this.getGamepad(this._physicalGamepadId)
if(gamepad === undefined){
console.log('[Gamepad] Received a vibration report but no physical gamepad is connected')
return
}
if(this._options.enable_vibration === false){
return
}
const rumbleData = {
startDelay: 0,
duration: report.durationMs,
// DroidDeck: the player's rumble strength setting (0..1).
weakMagnitude: report.rightMotorPercent * ((window as any).__ddRumble ?? 1),
strongMagnitude: report.leftMotorPercent * ((window as any).__ddRumble ?? 1),
leftTrigger: report.leftTriggerMotorPercent,
rightTrigger: report.rightTriggerMotorPercent,
}
// @ts-expect-error - dual-rumble is not in the types
if(gamepad.vibrationActuator?.type === 'dual-rumble') {
const intensityRumble = report.rightMotorPercent < .6 ? (.6 - report.rightMotorPercent) / 2 : 0
const intensityRumbleTriggers = (report.leftTriggerMotorPercent + report.rightTriggerMotorPercent) / 4
const endIntensity = Math.min(intensityRumble, intensityRumbleTriggers)
rumbleData.weakMagnitude = Math.min(1, (report.rightMotorPercent + endIntensity) * ((window as any).__ddRumble ?? 1))
// Set triggers to 0 as dual-rumble does not support Triggers
// @TODO: Check if we can improve rumble: https://github.com/MicrosoftEdge/MSEdgeExplainers/blob/main/GamepadHapticsActuatorTriggerRumble/explainer.md
rumbleData.leftTrigger = 0
rumbleData.rightTrigger = 0
gamepad.vibrationActuator?.playEffect('dual-rumble', rumbleData)
clearInterval(this._rumbleInterval)
if(report.repeat > 0) {
let repeatCount = report.repeat
this._rumbleInterval = setInterval(() => {
if(repeatCount <= 0){
clearInterval(this._rumbleInterval)
}
if(gamepad.vibrationActuator !== undefined) {
gamepad.vibrationActuator?.playEffect('dual-rumble', rumbleData)
}
repeatCount--
}, report.delayMs + report.durationMs)
}
} else {
console.log('[Gamepad] Unknown vibration type:', (gamepad.vibrationActuator as any).type)
}
}
normaliseAxis(value:number) {
if(Math.abs(value) < this._options.gamepad_deadzone) {
return 0
}
value = value - Math.sign(value) * this._options.gamepad_deadzone
value /= (1.0 - this._options.gamepad_deadzone)
return value
}
}
@@ -0,0 +1,143 @@
import xCloudPlayer from '../player'
export default class MouseKeyboard {
private _player: xCloudPlayer | undefined
private _index: number
private _listener = {
keyDown: this.onKeyDown.bind(this),
keyUp: this.onKeyUp.bind(this),
pointerMove: this.onPointerMove.bind(this),
pointerClick: this.onPointerClick.bind(this),
pointerScroll: this.onPointerScroll.bind(this),
lockMouseKeyboard: this.lockMouseKeyboard.bind(this),
}
constructor(index:number){
this._index = index
}
attach(xCloudPlayer:xCloudPlayer){
this._player = xCloudPlayer
this._player._channels.control.sendGamepadState(this._index, true, this)
// Mod the video UI to capture MKB input
if(this._player.getVideoElement() === undefined){
console.log('[MKB] VideoComponent is not attached. this._player._videoComponent is:', this._player.getVideoElement())
return
} else {
this._player.getVideoElement()?.addEventListener('pointermove', this._listener.pointerMove)
this._player.getVideoElement()?.addEventListener('pointerdown', this._listener.pointerClick)
this._player.getVideoElement()?.addEventListener('pointerup', this._listener.pointerClick)
this._player.getVideoElement()?.addEventListener('wheel', this._listener.pointerScroll)
this._player.getVideoElement()?.addEventListener('click', this._listener.lockMouseKeyboard)
window.addEventListener('keydown', this._listener.keyDown)
window.addEventListener('keyup', this._listener.keyUp)
this._player.getVideoElement()?.addEventListener('contextmenu', event => {
event.preventDefault()
})
}
}
detach(){
if(this._player === undefined){
console.log('[MKB] Player is not attached. this._player is:', this._player)
return
}
this._player._channels.control.sendGamepadState(this._index, false)
window.removeEventListener('keydown', this._listener.keyDown)
window.removeEventListener('keyup', this._listener.keyUp)
this._player.getVideoElement()?.removeEventListener('pointermove', this._listener.pointerMove)
this._player.getVideoElement()?.removeEventListener('pointerdown', this._listener.pointerClick)
this._player.getVideoElement()?.removeEventListener('pointerup', this._listener.pointerClick)
this._player.getVideoElement()?.removeEventListener('wheel', this._listener.pointerScroll)
this._player.getVideoElement()?.removeEventListener('click', this._listener.lockMouseKeyboard)
}
onPointerMove(event:PointerEvent){
this._player?._channels.input.queueMouseFrame({
X: event.movementX*2,
Y: event.movementY*2,
WheelX: 0,
WheelY: 0,
Buttons: event.buttons,
Relative: 0, // 0 = Relative, 1 = Absolute
})
}
onPointerClick(event:PointerEvent){
// console.log('Pointer clicked:', event)
this._player?._channels.input.queueMouseFrame({
X: event.movementX*2,
Y: event.movementY*2,
WheelX: 0,
WheelY: 0,
Buttons: event.buttons,
Relative: 0, // 0 = Relative, 1 = Absolute
})
}
onPointerScroll(event:WheelEvent){
console.log('[MKB] Pointer scrolled:', event)
}
onKeyDown(event:KeyboardEvent){
this._player?._channels.input.queueKeyboardFrame({
key: event.key,
keyCode: event.keyCode,
pressed: true,
})
event.preventDefault()
}
onKeyUp(event:KeyboardEvent){
this._player?._channels.input.queueKeyboardFrame({
key: event.key,
keyCode: event.keyCode,
pressed: false,
})
}
lockMouseKeyboard(){
if(this._player === undefined || this._player.getVideoElement() === undefined){
console.log('[MKB] Player or VideoComponent is not attached. Failed to lock Mouse & Keyboard because there is nothing to lock for.')
return
}
// @disable ts-expect-error requestPointerLock() is not defined to consume arguments
const promise = this._player?.getVideoElement()?.requestPointerLock({
unadjustedMovement: true,
}) as Promise<undefined> | undefined
if(promise === undefined){
console.log('[MKB] Failed to lock Mouse & Keyboard because the requestPointerLock() is undefined')
return
}
if ('keyboard' in navigator && 'lock' in (navigator.keyboard as any)) {
document.body.requestFullscreen().then(() => {
(navigator as any).keyboard.lock()
})
} else {
console.log('[MKB] Keyboard lock is not supported. Error:', 'NotSupportedError')
return
}
promise.then(() => {
console.log('[MKB] pointer is locked')
}).catch((error) => {
if (error.name === 'NotSupportedError') {
console.log('[MKB] Mouse pointer lock is not supported. Error:', 'NotSupportedError')
}
})
}
}
@@ -0,0 +1,70 @@
import xCloudPlayer from '../player'
export default class Touch {
private _player: xCloudPlayer | undefined
private _index: number
private _pointerEvents = {}
private _lastPointerId = -1
private _mouseDown = false
private _listener = {
onPointer: this.onPointer.bind(this),
}
constructor(index:number){
this._index = index
}
attach(xCloudPlayer:xCloudPlayer){
this._player = xCloudPlayer
this._player._channels.control.sendGamepadState(this._index, true, this)
// Mod the video UI to capture MKB input
if(this._player.getVideoElement() === undefined){
console.log('VideoComponent is not attached. this._player._videoComponent is:', this._player.getVideoElement())
return
} else {
this._player.getVideoElement()?.addEventListener('pointermove', this._listener.onPointer, { passive: false })
this._player.getVideoElement()?.addEventListener('pointerdown', this._listener.onPointer, { passive: false })
this._player.getVideoElement()?.addEventListener('pointerup', this._listener.onPointer, { passive: false })
}
}
detach(){
if(this._player === undefined){
console.log('Player is not attached. this._player is:', this._player)
return
}
this._player._channels.control.sendGamepadState(this._index, false)
this._player.getVideoElement()?.removeEventListener('pointermove', this._listener.onPointer)
this._player.getVideoElement()?.removeEventListener('pointerdown', this._listener.onPointer)
this._player.getVideoElement()?.removeEventListener('pointerup', this._listener.onPointer)
}
onPointer(event:PointerEvent) {
this._lastPointerId = event.pointerId
if(event.pointerType === 'mouse' && event.type === 'pointerdown'){
this._mouseDown = true
}
if(event.pointerType === 'mouse' && event.type === 'pointerup'){
this._mouseDown = false
}
if(event.pointerType === 'mouse' && event.type === 'pointermove' && this._mouseDown === false){
// ignore events if the pointer type is mouse and is not pressed.
} else {
this._player?._channels.input.queuePointerFrame({
events: [event],
})
}
event.preventDefault()
}
}
@@ -0,0 +1,143 @@
import ChatChannel from './channel/chat'
import ControlChannel from './channel/control'
import InputChannel from './channel/input'
import MessageChannel from './channel/message'
import Ice from './ice'
import Sdp from './sdp'
import Stats from './stats'
import VideoComponent from './render/video'
import WebGpuComponent from './render/webgpu'
import AudioComponent from './render/audio'
export default class xCloudPlayer {
_peerConnection = new RTCPeerConnection({})
_channels = {
chat: new ChatChannel(this),
control: new ControlChannel(this),
input: new InputChannel(this),
message: new MessageChannel(this),
}
private _elementId: string
private _isDestoyed: boolean = false
private _iceHelper = new Ice(this)
private _sdpHelper = new Sdp(this)
private _statsHelper = new Stats(this)
private _videoComponent: VideoComponent | WebGpuComponent | undefined
private _audioComponent: AudioComponent | undefined
constructor(elementId: string) {
console.log('xCloudPlayer constructing...');
this._elementId = elementId;
this._peerConnection.addTransceiver('audio', { direction: 'sendrecv' })
const videoTransceiver = this._peerConnection.addTransceiver('video', { direction: 'recvonly' })
videoTransceiver.setCodecPreferences(this._sdpHelper.getDefaultCodecPreferences())
this._peerConnection.ontrack = (event) => {
if(event.track.kind === 'video'){
// DroidDeck: a plain <video> element (hardware decode); WebGPU is not dependable in Android WebView.
this._videoComponent = new VideoComponent(this)
this._videoComponent.create(event.streams[0])
} else if(event.track.kind === 'audio'){
this._audioComponent = new AudioComponent(this)
this._audioComponent.create(event.streams[0])
} else {
console.log('[xPlayer] Detected an unknown stream type: ', event.track.kind)
}
}
console.log('xCloudPlayer constructed.');
}
init() {
console.log('xCloudPlayer init called.');
}
getElementId() {
return this._elementId
}
async createOffer() {
const offer = await this._peerConnection.createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: true,
})
const finalOffer = this._sdpHelper.setLocalSDP(offer)
this._peerConnection.setLocalDescription(finalOffer)
return finalOffer
}
async setRemoteSDP(sdp: string) {
const finalSdp = this._sdpHelper.setRemoteSDP(sdp)
return await this._peerConnection.setRemoteDescription({
type: 'answer',
sdp: finalSdp,
})
}
async getICECandidates() {
return this._iceHelper.getCandidates()
}
async setRemoteIceCandidates(candidates:RTCIceCandidate[]) {
// @TODO: Sort ipv4 and ipv6, and use best route as preference (ipv6 only etc)
this._iceHelper.setRemoteCandidates(candidates)
}
getVideoElement() {
if(this._videoComponent){
return this._videoComponent.getElement()
} else {
return undefined
}
}
getAudioElement() {
if(this._audioComponent){
return this._audioComponent.getElement()
} else {
return undefined
}
}
toggleDebugOverlay() {
if(this._videoComponent){
this._videoComponent.toggleDebugOverlay()
}
}
getStats() {
return this._statsHelper
}
destroy() {
if(this._isDestoyed === false){
this._peerConnection.close()
if(this._peerConnection.onconnectionstatechange){
this._peerConnection.onconnectionstatechange(new Event('connectionstatechanged'))
}
for(const channel in this._channels) {
this._channels[channel].destroy()
}
if(this._videoComponent){ this._videoComponent.destroy() }
if(this._audioComponent){ this._audioComponent.destroy() }
this._isDestoyed = true
} else {
console.log('Cannot destroy because the player is already destroyed.')
}
}
}
@@ -0,0 +1,38 @@
import xCloudPlayer from '../player'
export default class AudioComponent {
private _player:xCloudPlayer
private _element:HTMLAudioElement | undefined
constructor(player:any){
this._player = player
}
create(stream:MediaStream) {
const audioElement = document.createElement('audio')
audioElement.srcObject = stream
audioElement.autoplay = true
audioElement.muted = false
this._element = audioElement
const element = document.getElementById(this._player.getElementId())
if(element === null) {return}
element.appendChild(this._element)
}
getElement(){
return this._element
}
destroy(){
const streamHolder = document.getElementById(this._player.getElementId())
const element = streamHolder?.querySelector('audio')
if(element){
element.remove()
}
}
}
@@ -0,0 +1,118 @@
import xCloudPlayer from '../player'
import VideoComponent from './video'
import WebGPUComponent from './webgpu'
interface OverlayInterface {
debug: undefined | HTMLElement;
}
export default class Overlay {
private _player:xCloudPlayer
private _videoComponent:VideoComponent | WebGPUComponent
private _overlays:OverlayInterface = {
debug: undefined,
}
constructor(videoComponent:VideoComponent | WebGPUComponent, player:xCloudPlayer){
this._videoComponent = videoComponent
this._player = player
}
toggleDebug(){
if(this._overlays.debug === undefined){
this.createDebugOverlay()
} else {
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 = undefined
}
refreshDebugOverlay(){
if(this._overlays.debug !== undefined){
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'))
// Display rendering FPS if available
const frameStats = (this._videoComponent as any).getFrameStats?.();
if (frameStats) {
this._overlays.debug.appendChild(this.createLabel('Render FPS', frameStats.renderingFps.toString(), frameStats.renderingFps >= 58 ? 'ok' : 'warning'))
this._overlays.debug.appendChild(this.createLabel('Render Delay', frameStats.renderDelayMs.toString() + ' ms', frameStats.renderDelayMs < 16 ? 'ok' : frameStats.renderDelayMs < 33 ? 'warning' : 'error'))
this._overlays.debug.appendChild(this.createLabel('Dropped Frames', frameStats.droppedFrames.toString(), frameStats.droppedFrames === 0 ? 'ok' : frameStats.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'))
const rttButtonState = (this._player.getStats()._rtt*1000 > 75) ? 'error' : (this._player.getStats()._rtt*1000 > 40) ? 'warning' : 'ok'
this._overlays.debug.appendChild(this.createLabel('RTT', (this._player.getStats()._rtt*1000).toString()+' ms', rttButtonState))
setTimeout(() => {
this.refreshDebugOverlay()
}, 500)
}
}
createLabel(text:string, value:string = '', style:string = ''){
const labelStyle = {
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',
}
const element = document.createElement('span')
const colors = {
'error': 'rgb(220, 53, 69)',
'warning': 'rgb(255, 193, 7)',
'ok': 'rgb(25, 135, 84)',
}
if(value !== ''){
element.innerHTML = `${text}: <span style="background: ${colors[style]}; border-radius: 5px; padding: 4px; padding-left: 8px; padding-right: 8px;">${value}</span>`
} else {
element.innerText = text
}
for(const key in labelStyle){
element.style[key] = labelStyle[key]
}
return element
}
}
@@ -0,0 +1,76 @@
import xCloudPlayer from '../player'
import Overlay from './overlay'
export default class VideoComponent {
private _player:xCloudPlayer
private _element:HTMLVideoElement | undefined
private _overlay:Overlay
constructor(player:any){
this._player = player
this._overlay = new Overlay(this, this._player)
}
create(stream:MediaStream) {
const videoElement = document.createElement('video')
videoElement.srcObject = stream
videoElement.autoplay = true
videoElement.muted = true
videoElement.playsInline = true
videoElement.style.width = '100%'
videoElement.style.height = '100%'
videoElement.style.objectFit = 'contain'
videoElement.style.backgroundColor = 'black'
videoElement.style.touchAction = 'none'
// DroidDeck: a transparent poster hides Android WebView's grey play icon before the first frame.
videoElement.poster = 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'
const element = document.getElementById(this._player.getElementId())
if(element === null) {return}
this._element = videoElement
element.appendChild(this._element)
const isStatic = getComputedStyle(element).position === 'static' ? true : false
if(isStatic === true){
element.style.position = 'relative'
}
this._element.requestVideoFrameCallback(this.processVideoMetadata.bind(this))
}
processVideoMetadata(timestamp, data:VideoFrameCallbackMetadata) {
if(this._element === undefined) {return}
this._element.requestVideoFrameCallback(this.processVideoMetadata.bind(this))
this._player._channels.input.queueMetadataFrame({
serverDataKey: data.rtpTimestamp as number,
firstFramePacketArrivalTimeMs: data.receiveTime as number,
frameSubmittedTimeMs: data.receiveTime as number,
frameDecodedTimeMs: data.expectedDisplayTime,
frameRenderedTimeMs: data.expectedDisplayTime,
})
}
getElement(){
return this._element
}
toggleDebugOverlay(){
this._overlay.toggleDebug()
}
destroy(){
const streamHolder = document.getElementById(this._player.getElementId())
const element = streamHolder?.querySelector('video')
if(this._overlay !== undefined){
this._overlay.destroy()
}
if(element){
element.remove()
}
}
}
@@ -0,0 +1,483 @@
import xCloudPlayer from '../player'
import Overlay from './overlay'
export default class WebGpuComponent {
private _player:xCloudPlayer
private _element:HTMLCanvasElement | undefined
private _overlay:Overlay
private _isActive: boolean = true
private _gpuReader:GPUCanvasContext | undefined
private _gpuVideoTexture:GPUTexture | null = null
private _gpuDevice:GPUDevice | null = null
private _gpuContext:GPUCanvasContext | null = null
private _gpuBindGroup:GPUBindGroup | null = null
private _gpuPipeline:GPURenderPipeline | null = null
private _gpuSampler:GPUSampler | null = null
private _resizeObserver:ResizeObserver | null = null
private _containerElement:HTMLElement | null = null
private _isProcessingFrame: boolean = false
private _frameQueueSize: number = 0
private _droppedFrames: number = 0
private _lastFrameTime: number = 0
private _renderingFps: number = 0
private _frameTimeSamples: number[] = []
private _renderDelayMs: number = 0
private _renderDelaySamples: number[] = []
constructor(player:any){
this._player = player
this._overlay = new Overlay(this, this._player)
}
create(stream:MediaStream) {
const videoElement = document.createElement('canvas')
console.log('Creating WebGPU canvas element for stream:', stream);
// videoElement.srcObject = stream
// videoElement.autoplay = true
// videoElement.muted = true
// videoElement.playsInline = true
videoElement.width = 1920
videoElement.height = 1080
videoElement.style.willChange = 'contents'
// videoElement.style.imageRendering = 'pixelated' // or 'crisp-edges' for faster rendering
// videoElement.style.objectFit = 'contain'
// videoElement.style.backgroundColor = 'black'
// videoElement.style.touchAction = 'none'
const that:WebGpuComponent = this
navigator.gpu.requestAdapter().then((adapter:any) => {
if (!adapter) {
console.error('Failed to get GPU adapter');
return;
}
console.log('WebGPU adapter:', adapter);
adapter.requestDevice().then((device:any) => {
const context = videoElement.getContext("webgpu");
if (!context) {
console.error('Failed to get WebGPU context');
return;
}
const format = navigator.gpu.getPreferredCanvasFormat();
context.configure({
device,
format,
alphaMode: 'opaque',
// Enable desynchronized mode for lower latency and reduced jitter
usage: GPUTextureUsage.RENDER_ATTACHMENT
});
this._gpuDevice = device
this._gpuContext = context
// Additional WebGPU setup and rendering logic would go here
that._gpuVideoTexture = this._gpuDevice.createTexture({
size: [videoElement.width, videoElement.height],
format: "rgba8unorm",
usage: GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.COPY_DST |
GPUTextureUsage.RENDER_ATTACHMENT
});
const vertexModule = this._gpuDevice.createShaderModule({
code: `
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
@vertex
fn main(@builtin(vertex_index) VertexIndex: u32) -> VertexOutput {
var pos = array<vec2<f32>, 4>(
vec2<f32>(-1.0, -1.0),
vec2<f32>( 1.0, -1.0),
vec2<f32>(-1.0, 1.0),
vec2<f32>( 1.0, 1.0)
);
var uv = array<vec2<f32>, 4>(
vec2<f32>(0.0, 1.0),
vec2<f32>(1.0, 1.0),
vec2<f32>(0.0, 0.0),
vec2<f32>(1.0, 0.0)
);
var output: VertexOutput;
output.position = vec4<f32>(pos[VertexIndex], 0.0, 1.0);
output.uv = uv[VertexIndex];
return output;
}
`
});
const fragmentModule = this._gpuDevice.createShaderModule({
code: `
@group(0) @binding(0) var myTexture: texture_2d<f32>;
@group(0) @binding(1) var mySampler: sampler;
@fragment
fn main(@location(0) uv: vec2<f32>) -> @location(0) vec4<f32> {
// Get texture dimensions
let texSize = vec2<f32>(textureDimensions(myTexture));
let invTexSize = 1.0 / texSize;
// Edge-aware upscaling with Catmull-Rom interpolation
// Sample 3x3 neighborhood for edge detection
let p00 = textureSample(myTexture, mySampler, uv + vec2<f32>(-1.0, -1.0) * invTexSize).rgb;
let p10 = textureSample(myTexture, mySampler, uv + vec2<f32>( 0.0, -1.0) * invTexSize).rgb;
let p20 = textureSample(myTexture, mySampler, uv + vec2<f32>( 1.0, -1.0) * invTexSize).rgb;
let p01 = textureSample(myTexture, mySampler, uv + vec2<f32>(-1.0, 0.0) * invTexSize).rgb;
let p11 = textureSample(myTexture, mySampler, uv).rgb;
let p21 = textureSample(myTexture, mySampler, uv + vec2<f32>( 1.0, 0.0) * invTexSize).rgb;
let p02 = textureSample(myTexture, mySampler, uv + vec2<f32>(-1.0, 1.0) * invTexSize).rgb;
let p12 = textureSample(myTexture, mySampler, uv + vec2<f32>( 0.0, 1.0) * invTexSize).rgb;
let p22 = textureSample(myTexture, mySampler, uv + vec2<f32>( 1.0, 1.0) * invTexSize).rgb;
// Calculate Sobel edge detection
let edgeX = -p00 + p20 - 2.0*p01 + 2.0*p21 - p02 + p22;
let edgeY = p00 + 2.0*p10 + p20 - p02 - 2.0*p12 - p22;
let edge = length(edgeX) + length(edgeY);
// Sample with adaptive filtering based on edge strength
let centerSample = textureSample(myTexture, mySampler, uv);
var resultRgb = centerSample.rgb;
// For smooth areas, apply slight smoothing
// For edge areas, keep sharp sampling
if (edge < 0.1) {
// Smooth region: blend neighboring pixels for smoothness
let neighbors = (p10 + p01 + p21 + p12) * 0.25;
resultRgb = mix(resultRgb, neighbors, 0.15);
} else {
// Edge region: apply subtle sharpening to maintain clarity
resultRgb = resultRgb * 1.1 - (p10 + p01 + p21 + p12) * 0.025;
}
return vec4<f32>(resultRgb, centerSample.a);
}
`
});
// Render pipeline (vertex + fragment shader)
this._gpuPipeline = this._gpuDevice.createRenderPipeline({
vertex: {
module: vertexModule,
entryPoint: "main"
},
fragment: {
module: fragmentModule,
entryPoint: "main",
targets: [{ format: navigator.gpu.getPreferredCanvasFormat() }]
},
primitive: { topology: "triangle-strip" },
layout: "auto",
});
// Create sampler once and reuse it
this._gpuSampler = this._gpuDevice.createSampler({
magFilter: "linear",
minFilter: "linear",
addressModeU: "clamp-to-edge",
addressModeV: "clamp-to-edge",
});
// Process video frames from the MediaStream
// console.log('Stream tracks:', stream);
const track = stream.getVideoTracks()[0];
const processor = new MediaStreamTrackProcessor({ track });
this._gpuReader = processor.readable.getReader();
this._gpuBindGroup = this._gpuDevice.createBindGroup({
layout: this._gpuPipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: this._gpuVideoTexture.createView() },
{ binding: 1, resource: this._gpuSampler },
]
});
// Start rendering loop
that.processVideoData();
});
});
// console.log('WebGPU adapter:', adapter, navigator.gpu);
// const device = adapter.requestDevice();
// console.log('Device:', device);
// const context = videoElement.getContext("webgpu");
// console.log('Context:', context);
// const format = navigator.gpu.getPreferredCanvasFormat();
// context.configure({ device, format });
// Rest
const element = document.getElementById(this._player.getElementId())
if(element === null) {return}
this._element = videoElement
this._containerElement = element
element.appendChild(this._element)
const isStatic = getComputedStyle(element).position === 'static' ? true : false
if(isStatic === true){
element.style.position = 'relative'
}
// Make canvas fill the container
this._element.style.width = '100%'
this._element.style.height = '100%'
this._element.style.display = 'block'
// Set up resize observer to handle canvas resizing
this._resizeObserver = new ResizeObserver(() => {
this.resizeCanvas()
})
this._resizeObserver.observe(element)
// Initial resize
this.resizeCanvas()
// this._element.requestVideoFrameCallback(this.processVideoMetadata.bind(this))
}
async processVideoData(timestamp?:number) {
if (this._isProcessingFrame || !this._isActive) {
return;
}
this._isProcessingFrame = true;
try {
// Non-blocking read - check if frame is available without waiting
const { value: frame, done } = await this._gpuReader.read();
if (done || !this._isActive || !frame) {
this._isProcessingFrame = false;
return;
}
// Frame dropping: if multiple frames queued, skip older ones to maintain order and latency
// This prevents frames from being processed out of order
if (this._frameQueueSize > 0) {
frame.close();
this._droppedFrames++;
this._isProcessingFrame = false;
// Continue processing next frame immediately without delay
this.videoLoop();
return;
}
this._frameQueueSize++;
// Resize texture if video frame dimensions changed
if (this._gpuVideoTexture &&
(this._gpuVideoTexture.width !== frame.codedWidth ||
this._gpuVideoTexture.height !== frame.codedHeight)) {
this._gpuVideoTexture.destroy();
this._gpuVideoTexture = this._gpuDevice.createTexture({
size: [frame.codedWidth, frame.codedHeight],
format: "rgba8unorm",
usage: GPUTextureUsage.TEXTURE_BINDING |
GPUTextureUsage.COPY_DST |
GPUTextureUsage.RENDER_ATTACHMENT
});
// Recreate bind group with new texture
this._gpuBindGroup = this._gpuDevice.createBindGroup({
layout: this._gpuPipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: this._gpuVideoTexture.createView() },
{ binding: 1, resource: this._gpuSampler },
]
});
console.log(`Video texture resized to ${frame.codedWidth}x${frame.codedHeight}`);
}
const renderStartTime = performance.now();
// Track frame timing for FPS calculation
if (this._lastFrameTime > 0) {
const frameTime = renderStartTime - this._lastFrameTime;
this._frameTimeSamples.push(frameTime);
// Keep only last 60 samples for smooth FPS average
if (this._frameTimeSamples.length > 60) {
this._frameTimeSamples.shift();
}
// Update FPS based on average frame time
const avgFrameTime = this._frameTimeSamples.reduce((a, b) => a + b, 0) / this._frameTimeSamples.length;
this._renderingFps = Math.round(1000 / avgFrameTime);
}
this._lastFrameTime = renderStartTime;
// Upload the video frame to GPU
this._gpuDevice.queue.copyExternalImageToTexture(
{ source: frame },
{ texture: this._gpuVideoTexture },
[frame.codedWidth, frame.codedHeight]
);
// Render to canvas
const commandEncoder = this._gpuDevice.createCommandEncoder();
const passEncoder = commandEncoder.beginRenderPass({
colorAttachments: [{
view: this._gpuContext.getCurrentTexture().createView(),
loadOp: 'clear',
storeOp: 'store',
clearValue: { r: 0, g: 0, b: 0, a: 1 },
}],
});
passEncoder.setPipeline(this._gpuPipeline);
passEncoder.setBindGroup(0, this._gpuBindGroup);
passEncoder.draw(4);
passEncoder.end();
this._gpuDevice.queue.submit([commandEncoder.finish()]);
const renderEndTime = performance.now();
const renderDelay = renderEndTime - renderStartTime;
// Track render delay (time to process and submit frame to GPU)
this._renderDelaySamples.push(renderDelay);
// Keep only last 60 samples for smooth average
if (this._renderDelaySamples.length > 60) {
this._renderDelaySamples.shift();
}
// Update average render delay
this._renderDelayMs = Math.round(
this._renderDelaySamples.reduce((a, b) => a + b, 0) / this._renderDelaySamples.length * 100
) / 100; // Round to 2 decimal places
// Queue metadata frames with actual render timing
this._player._channels.input.queueMetadataFrame({
serverDataKey: frame.timestamp as number,
firstFramePacketArrivalTimeMs: frame.timestamp as number,
frameSubmittedTimeMs: renderStartTime,
frameDecodedTimeMs: renderStartTime,
frameRenderedTimeMs: renderEndTime,
})
frame.close();
this._frameQueueSize--;
} catch (error) {
console.error('Error processing video frame:', error);
} finally {
this._isProcessingFrame = false;
// Continue processing next frame immediately without vsync delay for low-latency streaming
if (this._isActive) {
this.videoLoop();
}
}
}
videoLoop() {
this.processVideoData();
}
resizeCanvas() {
if (!this._element || !this._containerElement || !this._gpuContext || !this._gpuDevice) {
return
}
// Get the display size (CSS pixels)
const displayWidth = this._containerElement.clientWidth
const displayHeight = this._containerElement.clientHeight
// Get device pixel ratio for sharp rendering on high-DPI displays
const devicePixelRatio = window.devicePixelRatio || 1
// Calculate the actual canvas size in device pixels
const width = Math.floor(displayWidth * devicePixelRatio)
const height = Math.floor(displayHeight * devicePixelRatio)
// Check if canvas needs resizing
if (this._element.width !== width || this._element.height !== height) {
this._element.width = width
this._element.height = height
// Reconfigure the WebGPU context with the new size
const format = navigator.gpu.getPreferredCanvasFormat()
this._gpuContext.configure({
device: this._gpuDevice,
format,
alphaMode: 'opaque',
usage: GPUTextureUsage.RENDER_ATTACHMENT
})
console.log(`Canvas resized to ${width}x${height} (display: ${displayWidth}x${displayHeight}, DPR: ${devicePixelRatio})`)
}
}
// processVideoMetadata(timestamp, data:VideoFrameCallbackMetadata) {
// if(this._element === undefined) {return}
// this._element.requestVideoFrameCallback(this.processVideoMetadata.bind(this))
// this._player._channels.input.queueMetadataFrame({
// serverDataKey: data.rtpTimestamp as number,
// firstFramePacketArrivalTimeMs: data.receiveTime as number,
// frameSubmittedTimeMs: data.receiveTime as number,
// frameDecodedTimeMs: data.expectedDisplayTime,
// frameRenderedTimeMs: data.expectedDisplayTime,
// })
// }
getElement(){
return this._element
}
toggleDebugOverlay(){
this._overlay.toggleDebug()
}
getFrameStats() {
return {
droppedFrames: this._droppedFrames,
queueSize: this._frameQueueSize,
isProcessing: this._isProcessingFrame,
renderingFps: this._renderingFps,
renderDelayMs: this._renderDelayMs
}
}
destroy(){
const streamHolder = document.getElementById(this._player.getElementId())
this._isActive = false
const element = streamHolder?.querySelector('canvas')
// Clean up GPU reader
if(this._gpuReader){
this._gpuReader.cancel();
this._gpuReader = undefined;
}
if(this._resizeObserver){
this._resizeObserver.disconnect()
this._resizeObserver = null
}
if(this._gpuVideoTexture){
this._gpuVideoTexture.destroy();
this._gpuVideoTexture = null;
}
if(this._overlay !== undefined){
this._overlay.destroy()
}
if(element){
element.remove()
}
if(this._element){
this._element.remove()
}
}
}
+127
View File
@@ -0,0 +1,127 @@
import xCloudPlayer from './player'
export default class Sdp {
private _player: xCloudPlayer
private _peerConnection:RTCPeerConnection
constructor(player:xCloudPlayer) {
this._player = player
this._peerConnection = player._peerConnection
}
getAvailableCodecs():Array<any> {
const capabilities = RTCRtpReceiver.getCapabilities('video')?.codecs
if(capabilities === undefined) {return []}
const codecs:Array<string> = []
for(const codec in capabilities) {
if(!codecs.includes(capabilities[codec].mimeType)) {codecs.push(capabilities[codec].mimeType)}
}
return codecs
}
// getServerCodecs() {
// for(const sender in this._peerConnection.getSenders()){
// console.log('Sender:', this._peerConnection.getSenders()[sender])
// }
// }
setLocalSDP(sdp:RTCSessionDescriptionInit) {
console.log('[SDP] setLocalSDP() Setting local SDP:', sdp)
// if(this._player._config.video_bitrate > 0){
// console.log('[SDP] Set max video bitrate to:', this._player._config.video_bitrate, 'kbps')
// sdp.sdp = this._setBitrate(sdp.sdp, 'video', this._player._config.video_bitrate)
// }
// if(this._player._config.audio_bitrate > 0){
// console.log('[SDP] Set max audio bitrate to:', this._player._config.audio_bitrate, 'kbps')
// sdp.sdp = this._setBitrate(sdp.sdp, 'audio', this._player._config.audio_bitrate)
// }
// if(this._player._config.audio_mono !== true){
// console.log('[SDP] setLocalSDP() Set audio to stereo')
// sdp.sdp = sdp.sdp?.replace('useinbandfec=1', 'useinbandfec=1; stereo=1')
// }
sdp.sdp = sdp.sdp?.replace('useinbandfec=1', 'useinbandfec=1; stereo=1')
return sdp
}
setRemoteSDP(sdp:string) {
console.log('[SDP] setRemoteSDP() Setting remote SDP:', sdp)
return sdp
}
getDefaultCodecPreferences() {
const capabilities = RTCRtpReceiver.getCapabilities('video')?.codecs
if(capabilities === undefined) {return []}
const t1:Array<any> = []
const t2:Array<any> = []
const t3:Array<any> = []
const t4:Array<any> = []
for(const codec in capabilities) {
if(capabilities[codec].mimeType.includes('H264')) {
if(capabilities[codec].sdpFmtpLine?.includes('profile-level-id=4d')) {
t1.push(capabilities[codec])
} else if(capabilities[codec].sdpFmtpLine?.includes('profile-level-id=42e')) {
t2.push(capabilities[codec])
} else if(capabilities[codec].sdpFmtpLine?.includes('profile-level-id=420')) {
t3.push(capabilities[codec])
}
} else if(capabilities[codec].mimeType.includes('ulpfec')) {
t4.push(capabilities[codec])
} else if(capabilities[codec].mimeType.includes('flexfec')) {
t4.push(capabilities[codec])
} else if(capabilities[codec].mimeType.includes('VP9')) {
t4.push(capabilities[codec])
} else if(capabilities[codec].mimeType.includes('VP8')) {
t4.push(capabilities[codec])
}
// else {
// t4.push(capabilities[codec])
// }
}
const codecOrder = [...t1, ...t2, ...t3, ...t4]
return codecOrder
}
_setBitrate(sdp, media, bitrate) {
const lines = sdp.split('\n')
let line = -1
for(let i=0; i < lines.length; i++) {
if(lines[i].indexOf('m='+media) === 0) {
line = i
break
}
}
if (line === -1) {
console.debug('Could not find the m line for', media)
return sdp
}
line++
while(lines[line].indexOf('i=') === 0 || lines[line].indexOf('c=') === 0) {
line++
}
if (lines[line].indexOf('b') === 0) {
lines[line] = 'b=AS:'+bitrate
return lines.join('\n')
}
let newLines = lines.slice(0, line)
newLines.push('b=AS:'+bitrate)
newLines = newLines.concat(lines.slice(line, lines.length))
return newLines.join('\n')
}
}
+107
View File
@@ -0,0 +1,107 @@
import xCloudPlayer from '../player'
export default class Stats {
private _player: xCloudPlayer
private _peerConnection:RTCPeerConnection
_videoCodec:string = ''
_audioCodec:string = ''
_videoWidth:number = 0
_videoHeight:number = 0
_videoFps:number = 0
_rtt:number = 0.0
_remoteHost:string = ''
_remotePort:number = 0
_remoteIsLocal = false
_remoteIsIpv6 = false
_activeRemoteCandidate:string = ''
constructor(player:xCloudPlayer) {
this._player = player
this._peerConnection = player._peerConnection
setInterval(this.loop.bind(this), 1000)
}
loop(){
this._peerConnection.getStats().then((stats) => {
stats.forEach((report) => {
// @TODO: Implement chat stats using type outbound-rtp
if(report.type === 'inbound-rtp'){
this.readInboundRtp(report)
} else if(report.type === 'candidate-pair' && report.packetsReceived !== undefined && report.packetsReceived > 0){
this.readCandidatePair(report)
} else if(report.type === 'remote-candidate'){
this.readRemoteCandidate(report)
}
})
})
}
readInboundRtp(report:RTCInboundRtpStreamStats){
if(report.kind === 'video' && report.codecId && report.packetsReceived !== undefined && report.packetsReceived > 0){
if(report.codecId.includes('profile-level-id=4d')){
this._videoCodec = 'H264 (High)'
} else if(report.codecId.includes('profile-level-id=42e')) {
this._videoCodec = 'H264 (Normal)'
} else if(report.codecId.includes('profile-level-id=420')) {
this._videoCodec = 'H264 (Low)'
} else {
this._videoCodec = 'Other ('+report.codecId+')'
}
if(report.frameWidth){ this._videoWidth = report.frameWidth }
if(report.frameHeight){ this._videoHeight = report.frameHeight }
if(report.framesPerSecond){ this._videoFps = report.framesPerSecond }
// report.jitter
} else if(report.kind === 'audio' && report.codecId && report.packetsReceived !== undefined && report.packetsReceived > 0){
this._audioCodec = report.codecId
// report.jitter
}
}
readCandidatePair(report){
this._activeRemoteCandidate = report.remoteCandidateId
this._rtt = report.currentRoundTripTime
}
readRemoteCandidate(report){
if(report.id !== this._activeRemoteCandidate) {return}
if(this.isPrivateIP(report.address)){
this._remoteIsLocal = true
} else {
this._remoteIsLocal = false
}
if(this.isIpv6(report.address)){
this._remoteIsIpv6 = true
} else {
this._remoteIsIpv6 = false
}
this._remoteHost = report.address
this._remotePort = report.port
}
isPrivateIP(ip) {
const parts = ip.split('.')
return parts[0] === '10' || (parts[0] === '172' && (parseInt(parts[1], 10) >= 16 && parseInt(parts[1], 10) <= 31)) || (parts[0] === '192' && parts[1] === '168')
}
isIpv6(ip) {
const parts = ip.split(':')
return parts.length > 3 ? true : false
}
}
@@ -0,0 +1,31 @@
export default class Teredo {
private _address:string
private _ipv4Address:string
private _ipv4Port:number
constructor(address:string){
this._address = address
const split_str = this._address.split(':')
const ipv4_part = split_str[6] + split_str[7]
const ipv4_port_part = split_str[5]
const ip_1 =~ parseInt(ipv4_part.substring(0, 2), 16) & 0xFF
const ip_2 =~ parseInt(ipv4_part.substring(2, 4), 16) & 0xFF
const ip_3 =~ parseInt(ipv4_part.substring(4, 6), 16) & 0xFF
const ip_4 =~ parseInt(ipv4_part.substring(6, 8), 16) & 0xFF
const port_1 =~ parseInt(ipv4_port_part.substring(0, 4), 16) & 0xFFFF
this._ipv4Address = ip_1+'.'+ip_2+'.'+ip_3+'.'+ip_4
this._ipv4Port = port_1
}
getIpv4Address(){
return this._ipv4Address
}
getIpv4Port(){
return this._ipv4Port
}
}
+385
View File
@@ -0,0 +1,385 @@
// Picture effects for the stream: the video is drawn through WebGL2 shaders onto a canvas over it.
// Base pass: sharpening (CAS / unsharp mask, from Better xCloud by redphx, MIT) plus DroidDeck's colour
// grading (brightness, contrast, saturation, gamma) and deband dither. Extra passes are DroidDeck's own
// screen effects (FXAA, Toon, CRT, NTSC, from Bannerlator). Off (no canvas) when nothing is set.
export interface Effects {
brightness: number // -50..50 (DroidDeck's slider units)
contrast: number // -100..100
gamma: number // 0.5..3
saturation: number // 0..200 %
sharpen: number // 0..10
sharpenMode: string // 'cas' | 'usm'
deband: number // 0..8 (dither amplitude in 8-bit steps)
fxaa: boolean
toon: boolean
crt: boolean
ntsc: boolean
maxFps: number // 0 = every frame
}
export function needsRenderer(e: Effects): boolean {
return e.brightness !== 0 || e.contrast !== 0 || Math.abs(e.gamma - 1) > 0.001 || e.saturation !== 100 ||
e.sharpen > 0 || e.deband > 0 || e.fxaa || e.toon || e.crt || e.ntsc || e.maxFps > 0
}
const VERTEX_300 = `#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); }`
const VERTEX_100 = `attribute vec2 position;
varying vec2 vUV;
void main() { vUV = position * 0.5 + 0.5; gl_Position = vec4(position, 0.0, 1.0); }`
const BASE = `#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);
}`
// Screen-effect shaders from Bannerlator (GLSL ES 1.00), unchanged.
const FXAA = `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;
}`
const TOON = `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);
}`
const CRT = `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);
}`
const NTSC = `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);
}`
interface Pass { program: WebGLProgram, uniforms: Map<string, WebGLUniformLocation | null> }
export class EffectRenderer {
private canvas: HTMLCanvasElement
private gl: WebGL2RenderingContext
private passes = new Map<string, Pass>()
private videoTexture: WebGLTexture
private targets: { framebuffer: WebGLFramebuffer, texture: WebGLTexture }[] = []
private width = 0
private height = 0
private frame = 0
private lastDraw = 0
private stopped = false
private idle = false
private pending = false
constructor(private video: HTMLVideoElement, private effects: Effects) {
this.canvas = document.createElement('canvas')
Object.assign(this.canvas.style, { position: 'absolute', inset: '0', width: '100%', height: '100%', objectFit: 'contain', zIndex: '1' })
video.parentElement?.appendChild(this.canvas)
const gl = this.canvas.getContext('webgl2', { alpha: false, antialias: false, depth: false, desynchronized: true, powerPreference: 'high-performance' })
if (gl === null) throw new Error('WebGL2 unavailable')
this.gl = gl
const buffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW)
this.passes.set('base', this.compile(VERTEX_300, BASE, ['screenTexture', 'resolution', 'filterId', 'sharpenFactor', 'brightness', 'contrast', 'gamma', 'saturation', 'deband']))
this.videoTexture = this.texture()
this.schedule()
}
update(effects: Effects) { this.effects = effects }
// Turning effects off hides the canvas but keeps the GL context: losing and recreating it can stall the stream.
pause() { if (this.idle || this.stopped) return; this.idle = true; this.canvas.style.display = 'none' }
resume(effects: Effects) {
this.effects = effects
if (!this.idle || this.stopped) return
this.idle = false
this.canvas.style.display = ''
this.schedule()
}
private schedule() {
if (this.pending) return
this.pending = true
this.video.requestVideoFrameCallback(this.onFrame)
}
private compile(vertex: string, fragment: string, names: string[]): Pass {
const gl = this.gl
const shader = (type: number, source: string) => {
const s = gl.createShader(type)!
gl.shaderSource(s, source)
gl.compileShader(s)
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) throw new Error(`shader: ${gl.getShaderInfoLog(s)}`)
return s
}
const program = gl.createProgram()!
gl.attachShader(program, shader(gl.VERTEX_SHADER, vertex))
gl.attachShader(program, shader(gl.FRAGMENT_SHADER, fragment))
gl.bindAttribLocation(program, 0, 'position')
gl.linkProgram(program)
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error(`program: ${gl.getProgramInfoLog(program)}`)
return { program, uniforms: new Map(names.map(name => [name, gl.getUniformLocation(program, name)])) }
}
private extra(name: string): Pass {
let pass = this.passes.get(name)
if (pass === undefined) {
const source = { fxaa: FXAA, toon: TOON, crt: CRT, ntsc: NTSC }[name]!
pass = this.compile(VERTEX_100, source, ['screenTexture', 'resolution', 'TextureSize', 'FrameCount'])
this.passes.set(name, pass)
}
return pass
}
private texture(): WebGLTexture {
const gl = this.gl
const texture = gl.createTexture()!
gl.bindTexture(gl.TEXTURE_2D, texture)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
return texture
}
private resize(width: number, height: number) {
const gl = this.gl
this.width = width; this.height = height
this.canvas.width = width; this.canvas.height = height
for (const target of this.targets) { gl.deleteFramebuffer(target.framebuffer); gl.deleteTexture(target.texture) }
this.targets = [0, 1].map(() => {
const texture = this.texture()
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, width, height, 0, gl.RGBA, gl.UNSIGNED_BYTE, null)
const framebuffer = gl.createFramebuffer()!
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer)
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0)
return { framebuffer, texture }
})
gl.bindFramebuffer(gl.FRAMEBUFFER, null)
}
private onFrame = (now: number) => {
this.pending = false
if (this.stopped || this.idle) return
this.schedule()
const max = this.effects.maxFps
if (max > 0 && now - this.lastDraw < 1000 / max - 2) return
this.lastDraw = now
try { this.draw() } catch (error) { (window as any).DDCloud?.log(`effects: ${String(error).slice(0, 200)}`); this.destroy() }
}
private draw() {
const gl = this.gl
const e = this.effects
const width = this.video.videoWidth, height = this.video.videoHeight
if (width === 0 || height === 0) return
if (width !== this.width || height !== this.height) this.resize(width, height)
this.frame++
gl.activeTexture(gl.TEXTURE0)
gl.bindTexture(gl.TEXTURE_2D, this.videoTexture)
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true)
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this.video)
const extras = ['fxaa', 'toon', 'crt', 'ntsc'].filter(name => (e as any)[name])
gl.viewport(0, 0, width, height)
gl.enableVertexAttribArray(0)
gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0)
const base = this.passes.get('base')!
gl.bindFramebuffer(gl.FRAMEBUFFER, extras.length > 0 ? this.targets[0].framebuffer : null)
gl.useProgram(base.program)
const u = base.uniforms
gl.uniform1i(u.get('screenTexture')!, 0)
gl.uniform2f(u.get('resolution')!, width, height)
gl.uniform1i(u.get('filterId')!, e.sharpenMode === 'usm' ? 1 : 2)
gl.uniform1f(u.get('sharpenFactor')!, e.sharpen)
gl.uniform1f(u.get('brightness')!, e.brightness / 100)
gl.uniform1f(u.get('contrast')!, e.contrast / 100)
gl.uniform1f(u.get('gamma')!, Math.min(5, Math.max(0.1, e.gamma)))
gl.uniform1f(u.get('saturation')!, e.saturation / 100)
gl.uniform1f(u.get('deband')!, e.deband)
gl.drawArrays(gl.TRIANGLES, 0, 3)
extras.forEach((name, index) => {
const pass = this.extra(name)
const source = this.targets[index % 2]
const last = index === extras.length - 1
gl.bindFramebuffer(gl.FRAMEBUFFER, last ? null : this.targets[(index + 1) % 2].framebuffer)
gl.useProgram(pass.program)
gl.bindTexture(gl.TEXTURE_2D, source.texture)
const pu = pass.uniforms
gl.uniform1i(pu.get('screenTexture') ?? null, 0)
gl.uniform2f(pu.get('resolution') ?? null, width, height)
gl.uniform2f(pu.get('TextureSize') ?? null, width, height)
gl.uniform1i(pu.get('FrameCount') ?? null, this.frame)
gl.drawArrays(gl.TRIANGLES, 0, 3)
})
}
destroy() {
this.stopped = true
this.canvas.remove()
this.gl.getExtension('WEBGL_lose_context')?.loseContext()
}
}
+34
View File
@@ -0,0 +1,34 @@
// Offer tweaks after Better xCloud by redphx (MIT, src/utils/sdp.ts): prefer an H.264 profile, cap bitrate.
/** Moves the H.264 payloads of the chosen profile to the front of the video m-line. */
export function preferH264Profile(sdp: string, profile: 'high' | 'normal' | 'low'): string {
const prefix = profile === 'high' ? '4d' : (profile === 'low' ? '420' : '42e')
const preferred: string[] = []
for (const match of sdp.matchAll(/a=fmtp:(\d+).*profile-level-id=([0-9a-f]{6})/g)) {
if (match[2].startsWith(prefix)) preferred.push(match[1])
}
if (preferred.length === 0) return sdp
const lines = sdp.split('\r\n')
const index = lines.findIndex(line => line.startsWith('m=video'))
if (index < 0) return sdp
const parts = lines[index].trim().split(' ')
const ids = parts.slice(3).filter(id => !preferred.includes(id))
lines[index] = parts.slice(0, 3).concat(preferred, ids).join(' ')
return lines.join('\r\n')
}
/** Sets b=AS (kbit/s) on the video m-section. */
export function capVideoBitrate(sdp: string, kbps: number): string {
const lines = sdp.split('\r\n')
const start = lines.findIndex(line => line.startsWith('m=video'))
if (start < 0) return sdp
const bLine = `b=AS:${kbps}`
for (let i = start + 1; i <= lines.length; i++) {
const line = lines[i]
if (line === undefined || line.startsWith('m=')) { lines.splice(i, 0, bLine); break }
if (line.startsWith('i=') || line.startsWith('c=')) continue
if (line.startsWith('b=AS:')) { lines[i] = bLine; break }
lines.splice(i, 0, bLine); break
}
return lines.join('\r\n')
}
+68
View File
@@ -0,0 +1,68 @@
// Performance overlay in the top-right or top-left corner, from WebRTC getStats. Levels: 1 fps; 2 + ping, resolution;
// 3 + bitrate, decode time, packet loss; 4 + codec, jitter, dropped frames, server. Horizontal or vertical.
export class StreamStats {
private element: HTMLDivElement
private timer: ReturnType<typeof setInterval> | undefined
private level = 0
private vertical = false
private left = false
private server = ''
private last = { bytes: 0, time: 0, decode: 0, frames: 0, lost: 0, received: 0 }
constructor(private pc: RTCPeerConnection) {
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)
}
show(level: number, vertical: boolean, server: string, left = false) {
this.level = level; this.vertical = vertical; this.server = server; this.left = left
// Top left keeps clear of the clock Xbox draws top right while its guide is open.
Object.assign(this.element.style, left ? { left: '8px', right: '', textAlign: 'left' } : { left: '', right: '8px', textAlign: 'right' })
this.element.style.display = level > 0 ? 'block' : 'none'
if (level > 0 && this.timer === undefined) { this.timer = setInterval(() => this.update(), 1000); this.update() }
if (level === 0 && this.timer !== undefined) { clearInterval(this.timer); this.timer = undefined }
}
private async update() {
const report = await this.pc.getStats()
let video: any, pair: any
const codecs = new Map<string, any>()
report.forEach((s: any) => {
if (s.type === 'inbound-rtp' && s.kind === 'video') video = s
else if (s.type === 'candidate-pair' && s.state === 'succeeded' && s.nominated) pair = s
else if (s.type === 'codec') codecs.set(s.id, s)
})
if (video === undefined) return
const seconds = this.last.time > 0 ? (video.timestamp - this.last.time) / 1000 : 0
const mbps = seconds > 0 ? (video.bytesReceived - this.last.bytes) * 8 / seconds / 1e6 : 0
const frames = (video.framesDecoded ?? 0) - this.last.frames
const decodeMs = frames > 0 ? ((video.totalDecodeTime ?? 0) - this.last.decode) * 1000 / frames : 0
const lost = (video.packetsLost ?? 0) - this.last.lost
const received = (video.packetsReceived ?? 0) - this.last.received
const loss = lost + received > 0 ? lost * 100 / (lost + received) : 0
this.last = { bytes: video.bytesReceived, time: video.timestamp, decode: video.totalDecodeTime ?? 0,
frames: video.framesDecoded ?? 0, lost: video.packetsLost ?? 0, received: video.packetsReceived ?? 0 }
const items: string[] = ['web', `${Math.round(video.framesPerSecond ?? 0)} fps`]
if (this.level >= 2) {
if (pair?.currentRoundTripTime !== undefined) items.push(`ping ${Math.round(pair.currentRoundTripTime * 1000)} ms`)
items.push(`${video.frameWidth ?? '?'}x${video.frameHeight ?? '?'}`)
}
if (this.level >= 3) items.push(`${mbps.toFixed(1)} Mb/s`, `decode ${decodeMs.toFixed(1)} ms`, `loss ${loss.toFixed(1)}%`)
if (this.level >= 4) {
const codec = codecs.get(video.codecId)
if (codec) items.push(String(codec.mimeType ?? '').replace('video/', ''))
items.push(`jitter ${Math.round((video.jitter ?? 0) * 1000)} ms`, `dropped ${video.framesDropped ?? 0}`)
if (this.server) items.push(this.server)
}
this.element.textContent = items.join(this.vertical ? '\n' : ' · ')
}
destroy() { this.show(0, this.vertical, this.server, this.left); this.element.remove() }
}
+2
View File
@@ -0,0 +1,2 @@
// Stand-in for the "uuid" package (only v4 is used by the Greenlight player).
export function v4(): string { return crypto.randomUUID() }