mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 03:21:08 +00:00
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.
143 lines
4.2 KiB
TypeScript
143 lines
4.2 KiB
TypeScript
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.')
|
|
}
|
|
}
|
|
} |