mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 02:51:10 +00:00
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:
1 parent
5ca1f610ab
commit
c5d7cdc058
78 files changed
+7835
-45
No files matched your search
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) \
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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++)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
@@ -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():
|
||||
|
||||
@@ -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).
|
||||
@@ -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"
|
||||
@@ -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))
|
||||
})
|
||||
@@ -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, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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() }
|
||||
Reference in new issue
Block a user