// 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 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 { 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 { 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