Files
DroidDeck/tools/xcloud-player/streamstats.ts
T
The412Banner c5d7cdc058 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.
2026-10-24 23:17:57 -04:00

69 lines
4.0 KiB
TypeScript

// 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() }
}