Add initial automatic aspect ratio change

Depending on the configuration param coreAspectRatio, video streams may have automatic aspect ratio correction in the browser with the value provided by the cores themselves.
This commit is contained in:
Sergey Stepanov 2023-11-01 01:21:58 +03:00 committed by sergystepanov
parent d805ba8eb8
commit 2e91feb861
22 changed files with 296 additions and 167 deletions

View file

@ -62,8 +62,9 @@ func (o *Out) GetPayload() any { return o.Payload }
// Packet codes:
//
// x, 1xx - user codes
// 2xx - worker codes
// x, 1xx - user codes
// 15x - webrtc data exchange codes
// 2xx - worker codes
const (
CheckLatency PT = 3
InitSession PT = 4
@ -84,6 +85,7 @@ const (
CloseRoom PT = 202
IceCandidate = WebrtcIce
TerminateSession PT = 204
AppVideoChange PT = 150
)
func (p PT) String() string {
@ -124,6 +126,8 @@ func (p PT) String() string {
return "CloseRoom"
case TerminateSession:
return "TerminateSession"
case AppVideoChange:
return "AppVideoChange"
default:
return "Unknown"
}
@ -160,3 +164,5 @@ func UnwrapChecked[T any](bytes []byte, err error) (*T, error) {
}
return Unwrap[T](bytes), nil
}
func Wrap(t any) ([]byte, error) { return json.Marshal(t) }

View file

@ -11,6 +11,10 @@ type (
RecordUser string `json:"record_user,omitempty"`
PlayerIndex int `json:"player_index"`
}
GameStartUserResponse struct {
RoomId string `json:"roomId"`
Av *AppVideoInfo `json:"av"`
}
IceServer struct {
Urls string `json:"urls,omitempty"`
Username string `json:"username,omitempty"`

View file

@ -33,6 +33,7 @@ type (
}
StartGameResponse struct {
Room
AV *AppVideoInfo `json:"av"`
Record bool
}
RecordGameRequest[T Id] struct {
@ -59,4 +60,10 @@ type (
Stateful[T]
}
WebrtcInitResponse string
AppVideoInfo struct {
W int `json:"w"`
H int `json:"h"`
A float32 `json:"a"`
}
)

View file

@ -118,14 +118,6 @@ emulator:
# (removed)
threads: 0
aspectRatio:
# enable aspect ratio changing
# (experimental)
keep: false
# recalculate emulator game frame size to the given WxH
width: 320
height: 240
# enable autosave for emulator states if set to a non-zero value of seconds
autosaveSec: 0
@ -189,6 +181,7 @@ emulator:
# - isGlAllowed (bool)
# - usesLibCo (bool)
# - hasMultitap (bool)
# - coreAspectRatio (bool) -- correct the aspect ratio on the client with the info from the core.
# - vfr (bool)
# (experimental)
# Enable variable frame rate only for cores that can't produce a constant frame rate.
@ -210,6 +203,7 @@ emulator:
mgba_audio_low_pass_filter: enabled
mgba_audio_low_pass_range: 40
pcsx:
coreAspectRatio: true
lib: pcsx_rearmed_libretro
roms: [ "cue", "chd" ]
# example of folder override
@ -227,6 +221,8 @@ emulator:
nes:
lib: nestopia_libretro
roms: [ "nes" ]
options:
nestopia_aspect: "uncorrected"
snes:
lib: snes9x_libretro
roms: [ "smc", "sfc", "swc", "fig", "bs" ]

View file

@ -8,11 +8,6 @@ import (
type Emulator struct {
Threads int
AspectRatio struct {
Keep bool
Width int
Height int
}
Storage string
LocalPath string
Libretro LibretroConfig
@ -44,20 +39,21 @@ type LibretroRepoConfig struct {
}
type LibretroCoreConfig struct {
AltRepo bool
AutoGlContext bool // hack: keep it here to pass it down the emulator
Folder string
Hacks []string
HasMultitap bool
Height int
IsGlAllowed bool
Lib string
Options map[string]string
Roms []string
Scale float64
UsesLibCo bool
VFR bool
Width int
AltRepo bool
AutoGlContext bool // hack: keep it here to pass it down the emulator
CoreAspectRatio bool
Folder string
Hacks []string
HasMultitap bool
Height int
IsGlAllowed bool
Lib string
Options map[string]string
Roms []string
Scale float64
UsesLibCo bool
VFR bool
Width int
}
type CoreInfo struct {

View file

@ -37,4 +37,6 @@ func (u *User) SendWebrtcOffer(sdp string) { u.Notify(api.WebrtcOffer, sdp) }
func (u *User) SendWebrtcIceCandidate(candidate string) { u.Notify(api.WebrtcIce, candidate) }
// StartGame signals the user that everything is ready to start a game.
func (u *User) StartGame() { u.Notify(api.StartGame, u.w.RoomId) }
func (u *User) StartGame(av *api.AppVideoInfo) {
u.Notify(api.StartGame, api.GameStartUserResponse{RoomId: u.w.RoomId, Av: av})
}

View file

@ -56,7 +56,7 @@ func (u *User) HandleStartGame(rq api.GameStartUserRequest, launcher games.Launc
return
}
u.log.Info().Str("id", startGameResp.Rid).Msg("Received room response from worker")
u.StartGame()
u.StartGame(startGameResp.AV)
// send back recording status
if conf.Recording.Enabled && rq.Record {

View file

@ -32,13 +32,13 @@ func (p *Peer) NewCall(vCodec, aCodec string, onICECandidate func(ice any)) (sdp
if p.conn != nil && p.conn.ConnectionState() == webrtc.PeerConnectionStateConnected {
return
}
p.log.Info().Msg("WebRTC start")
p.log.Debug().Msg("WebRTC start")
if p.conn, err = p.api.NewPeer(); err != nil {
return "", err
return
}
p.conn.OnICECandidate(p.handleICECandidate(onICECandidate))
// plug in the [video] track (out)
video, err := newTrack("video", "game-video", vCodec)
video, err := newTrack("video", "video", vCodec)
if err != nil {
return "", err
}
@ -49,7 +49,7 @@ func (p *Peer) NewCall(vCodec, aCodec string, onICECandidate func(ice any)) (sdp
p.log.Debug().Msgf("Added [%s] track", video.Codec().MimeType)
// plug in the [audio] track (out)
audio, err := newTrack("audio", "game-audio", aCodec)
audio, err := newTrack("audio", "audio", aCodec)
if err != nil {
return "", err
}
@ -59,21 +59,19 @@ func (p *Peer) NewCall(vCodec, aCodec string, onICECandidate func(ice any)) (sdp
p.log.Debug().Msgf("Added [%s] track", audio.Codec().MimeType)
p.a = audio
// plug in the [input] data channel (in)
if err = p.addInputChannel("game-input"); err != nil {
// plug in the [data] channel (in and out)
if err = p.addDataChannel("data"); err != nil {
return "", err
}
p.log.Debug().Msg("Added [input/bytes] chan")
p.log.Debug().Msg("Added [data] chan")
p.conn.OnICEConnectionStateChange(p.handleICEState(func() {
p.log.Info().Msg("Start streaming")
}))
p.conn.OnICEConnectionStateChange(p.handleICEState(func() { p.log.Info().Msg("Connected") }))
// Stream provider supposes to send offer
offer, err := p.conn.CreateOffer(nil)
if err != nil {
return "", err
}
p.log.Info().Msg("Created Offer")
p.log.Debug().Msg("Created Offer")
err = p.conn.SetLocalDescription(offer)
if err != nil {
@ -210,15 +208,16 @@ func (p *Peer) Disconnect() {
p.log.Debug().Msg("WebRTC stop")
}
// addInputChannel creates a new WebRTC data channel for user input.
// addDataChannel creates a new WebRTC data channel for user input.
// Default params -- ordered: true, negotiated: false.
func (p *Peer) addInputChannel(label string) error {
func (p *Peer) addDataChannel(label string) error {
ch, err := p.conn.CreateDataChannel(label, nil)
if err != nil {
return err
}
ch.OnOpen(func() {
p.log.Debug().Str("label", ch.Label()).Uint16("id", *ch.ID()).Msg("Data channel [input] opened")
p.log.Debug().Str("label", ch.Label()).Uint16("id", *ch.ID()).
Msg("Data channel [input] opened")
})
ch.OnError(p.logx)
ch.OnMessage(func(m webrtc.DataChannelMessage) {

View file

@ -2,6 +2,8 @@ package app
type App interface {
AudioSampleRate() int
AspectRatio() float32
AspectEnabled() bool
Init() error
ViewportSize() (int, int)
Start()
@ -9,6 +11,7 @@ type App interface {
SetAudioCb(func(Audio))
SetVideoCb(func(Video))
SetDataCb(func([]byte))
SendControl(port int, data []byte)
}

View file

@ -14,9 +14,6 @@ type Caged struct {
base *Frontend // maintains the root for mad embedding
conf CagedConf
log *logger.Logger
w, h int
OnSysInfoChange func()
}
type CagedConf struct {
@ -78,6 +75,8 @@ func (c *Caged) EnableCloudStorage(uid string, storage cloud.Storage) {
}
}
func (c *Caged) AspectEnabled() bool { return c.base.nano.Aspect }
func (c *Caged) AspectRatio() float32 { return c.base.AspectRatio() }
func (c *Caged) PixFormat() uint32 { return c.Emulator.PixFormat() }
func (c *Caged) Rotation() uint { return c.Emulator.Rotation() }
func (c *Caged) AudioSampleRate() int { return c.Emulator.AudioSampleRate() }

View file

@ -3,7 +3,6 @@ package libretro
import (
"errors"
"fmt"
"math"
"path/filepath"
"sync"
"sync/atomic"
@ -20,6 +19,7 @@ import (
type Emulator interface {
SetAudioCb(func(app.Audio))
SetVideoCb(func(app.Video))
SetDataCb(func([]byte))
LoadCore(name string)
LoadGame(path string) error
FPS() int
@ -57,6 +57,7 @@ type Frontend struct {
log *logger.Logger
nano *nanoarch.Nanoarch
onAudio func(app.Audio)
onData func([]byte)
onVideo func(app.Video)
storage Storage
scale float64
@ -90,6 +91,7 @@ const (
var (
audioPool sync.Pool
noAudio = func(app.Audio) {}
noData = func([]byte) {}
noVideo = func(app.Video) {}
videoPool sync.Pool
)
@ -135,6 +137,7 @@ func NewFrontend(conf config.Emulator, log *logger.Logger) (*Frontend, error) {
input: NewGameSessionInput(),
log: log,
onAudio: noAudio,
onData: noData,
onVideo: noVideo,
storage: store,
th: conf.Threads,
@ -153,14 +156,15 @@ func NewFrontend(conf config.Emulator, log *logger.Logger) (*Frontend, error) {
func (f *Frontend) LoadCore(emu string) {
conf := f.conf.GetLibretroCoreConfig(emu)
meta := nanoarch.Metadata{
AutoGlContext: conf.AutoGlContext,
Hacks: conf.Hacks,
HasMultitap: conf.HasMultitap,
HasVFR: conf.VFR,
IsGlAllowed: conf.IsGlAllowed,
LibPath: conf.Lib,
Options: conf.Options,
UsesLibCo: conf.UsesLibCo,
AutoGlContext: conf.AutoGlContext,
Hacks: conf.Hacks,
HasMultitap: conf.HasMultitap,
HasVFR: conf.VFR,
IsGlAllowed: conf.IsGlAllowed,
LibPath: conf.Lib,
Options: conf.Options,
UsesLibCo: conf.UsesLibCo,
CoreAspectRatio: conf.CoreAspectRatio,
}
f.mu.Lock()
scale := 1.0
@ -224,6 +228,13 @@ func (f *Frontend) SetVideoChangeCb(fn func()) { f.nano.OnSystemAvInfo = fn }
func (f *Frontend) Start() {
f.log.Debug().Msgf("frontend start")
if f.nano.Stopped.Load() {
f.log.Warn().Msgf("frontend stopped during the start")
f.mui.Lock()
defer f.mui.Unlock()
f.Shutdown()
return
}
f.mui.Lock()
f.done = make(chan struct{})
@ -269,6 +280,7 @@ func (f *Frontend) Start() {
}
}
func (f *Frontend) AspectRatio() float32 { return f.nano.AspectRatio() }
func (f *Frontend) AudioSampleRate() int { return f.nano.AudioSampleRate() }
func (f *Frontend) FPS() int { return f.nano.VideoFramerate() }
func (f *Frontend) Flipped() bool { return f.nano.IsGL() }
@ -286,6 +298,7 @@ func (f *Frontend) SaveGameState() error { return f.Save() }
func (f *Frontend) Scale() float64 { return f.scale }
func (f *Frontend) SetAudioCb(cb func(app.Audio)) { f.onAudio = cb }
func (f *Frontend) SetSessionId(name string) { f.storage.SetMainSaveName(name) }
func (f *Frontend) SetDataCb(cb func([]byte)) { f.onData = cb }
func (f *Frontend) SetVideoCb(ff func(app.Video)) { f.onVideo = ff }
func (f *Frontend) Tick() { f.mu.Lock(); f.nano.Run(); f.mu.Unlock() }
func (f *Frontend) ToggleMultitap() { f.nano.ToggleMultitap() }
@ -296,18 +309,6 @@ func (f *Frontend) ViewportCalc() (nw int, nh int) {
w, h := f.FrameSize()
nw, nh = w, h
aspect, aw, ah := f.conf.AspectRatio.Keep, f.conf.AspectRatio.Width, f.conf.AspectRatio.Height
// calc the aspect ratio
if aspect && aw > 0 && ah > 0 {
ratio := float64(w) / float64(ah)
nw = int(math.Round(float64(ah)*ratio/2) * 2)
nh = ah
if nw > aw {
nw = aw
nh = int(math.Round(float64(aw)/ratio/2) * 2)
}
}
if f.IsPortrait() {
nw, nh = nh, nw
}

View file

@ -52,7 +52,7 @@ type Nanoarch struct {
reserved chan struct{} // limits concurrent use
Rot uint
serializeSize C.size_t
stopped atomic.Bool
Stopped atomic.Bool
sys struct {
av C.struct_retro_system_av_info
i C.struct_retro_system_info
@ -70,6 +70,7 @@ type Nanoarch struct {
PixFmt PixFmt
}
vfr bool
Aspect bool
sdlCtx *graphics.SDL
hackSkipHwContextDestroy bool
limiter func(func())
@ -91,14 +92,15 @@ type FrameInfo struct {
}
type Metadata struct {
LibPath string // the full path to some emulator lib
IsGlAllowed bool
UsesLibCo bool
AutoGlContext bool
HasMultitap bool
HasVFR bool
Options map[string]string
Hacks []string
LibPath string // the full path to some emulator lib
IsGlAllowed bool
UsesLibCo bool
AutoGlContext bool
HasMultitap bool
HasVFR bool
Options map[string]string
Hacks []string
CoreAspectRatio bool
}
type PixFmt struct {
@ -122,7 +124,7 @@ func (p PixFmt) String() string {
// Nan0 is a global link for C callbacks to Go
var Nan0 = Nanoarch{
reserved: make(chan struct{}, 1), // this thing forbids concurrent use of the emulator
stopped: atomic.Bool{},
Stopped: atomic.Bool{},
limiter: func(fn func()) { fn() },
Handlers: Handlers{
OnDpad: func(uint, uint) int16 { return 0 },
@ -144,13 +146,14 @@ func NewNano(localPath string) *Nanoarch {
return nano
}
func (n *Nanoarch) AspectRatio() float32 { return float32(n.sys.av.geometry.aspect_ratio) }
func (n *Nanoarch) AudioSampleRate() int { return int(n.sys.av.timing.sample_rate) }
func (n *Nanoarch) VideoFramerate() int { return int(n.sys.av.timing.fps) }
func (n *Nanoarch) IsPortrait() bool { return 90 == n.Rot%180 }
func (n *Nanoarch) BaseWidth() int { return int(n.sys.av.geometry.base_width) }
func (n *Nanoarch) BaseHeight() int { return int(n.sys.av.geometry.base_height) }
func (n *Nanoarch) WaitReady() { <-n.reserved }
func (n *Nanoarch) Close() { n.stopped.Store(true); n.reserved <- struct{}{} }
func (n *Nanoarch) Close() { n.Stopped.Store(true); n.reserved <- struct{}{} }
func (n *Nanoarch) SetLogger(log *logger.Logger) { n.log = log }
func (n *Nanoarch) SetVideoDebounce(t time.Duration) { n.limiter = NewLimit(t) }
@ -158,6 +161,7 @@ func (n *Nanoarch) CoreLoad(meta Metadata) {
var err error
n.LibCo = meta.UsesLibCo
n.vfr = meta.HasVFR
n.Aspect = meta.CoreAspectRatio
n.Video.gl.autoCtx = meta.AutoGlContext
n.Video.gl.enabled = meta.IsGlAllowed
@ -258,12 +262,17 @@ func (n *Nanoarch) LoadGame(path string) error {
return fmt.Errorf("core failed to load ROM: %v", path)
}
C.bridge_retro_get_system_av_info(retroGetSystemAVInfo, &n.sys.av)
var av C.struct_retro_system_av_info
C.bridge_retro_get_system_av_info(retroGetSystemAVInfo, &av)
n.log.Info().Msgf("System A/V >>> %vx%v (%vx%v), [%vfps], AR [%v], audio [%vHz]",
n.sys.av.geometry.base_width, n.sys.av.geometry.base_height,
n.sys.av.geometry.max_width, n.sys.av.geometry.max_height,
n.sys.av.timing.fps, n.sys.av.geometry.aspect_ratio, n.sys.av.timing.sample_rate,
av.geometry.base_width, av.geometry.base_height,
av.geometry.max_width, av.geometry.max_height,
av.timing.fps, av.geometry.aspect_ratio, av.timing.sample_rate,
)
if isGeometryDifferent(av.geometry) {
geometryChange(av.geometry)
}
n.sys.av = av
n.serializeSize = C.bridge_retro_serialize_size(retroSerializeSize)
n.log.Info().Msgf("Save file size: %v", byteCountBinary(int64(n.serializeSize)))
@ -273,7 +282,7 @@ func (n *Nanoarch) LoadGame(path string) error {
n.log.Info().Msgf("variable framerate (VFR) is enabled")
}
n.stopped.Store(false)
n.Stopped.Store(false)
if n.Video.gl.enabled {
bufS := uint(n.sys.av.geometry.max_width*n.sys.av.geometry.max_height) * n.Video.PixFmt.BPP
@ -348,6 +357,7 @@ func (n *Nanoarch) Shutdown() {
}
setRotation(0)
Nan0.sys.av = C.struct_retro_system_av_info{}
if err := closeLib(coreLib); err != nil {
n.log.Error().Err(err).Msg("lib close failed")
}
@ -376,7 +386,7 @@ func (n *Nanoarch) Run() {
}
func (n *Nanoarch) IsGL() bool { return n.Video.gl.enabled }
func (n *Nanoarch) IsStopped() bool { return n.stopped.Load() }
func (n *Nanoarch) IsStopped() bool { return n.Stopped.Load() }
func videoSetPixelFormat(format uint32) (C.bool, error) {
switch format {
@ -550,7 +560,7 @@ var (
//export coreVideoRefresh
func coreVideoRefresh(data unsafe.Pointer, width, height uint, packed uint) {
if Nan0.stopped.Load() {
if Nan0.Stopped.Load() {
Nan0.log.Warn().Msgf(">>> skip video")
return
}
@ -636,7 +646,7 @@ func coreAudioSample(l, r C.int16_t) {
//export coreAudioSampleBatch
func coreAudioSampleBatch(data unsafe.Pointer, frames C.size_t) C.size_t {
if Nan0.stopped.Load() {
if Nan0.Stopped.Load() {
if Nan0.log.GetLevel() < logger.InfoLevel {
Nan0.log.Warn().Msgf(">>> skip %v audio frames", frames)
}
@ -686,27 +696,18 @@ func coreEnvironment(cmd C.unsigned, data unsafe.Pointer) C.bool {
switch cmd {
case C.RETRO_ENVIRONMENT_SET_SYSTEM_AV_INFO:
Nan0.sys.av = *(*C.struct_retro_system_av_info)(data)
Nan0.log.Debug().Msgf(">>> system av change: %v", Nan0.sys.av)
if Nan0.OnSystemAvInfo != nil {
go Nan0.OnSystemAvInfo()
Nan0.log.Debug().Msgf("retro_set_system_av_info")
av := *(*C.struct_retro_system_av_info)(data)
if isGeometryDifferent(av.geometry) {
geometryChange(av.geometry)
}
return true
case C.RETRO_ENVIRONMENT_SET_GEOMETRY:
Nan0.log.Debug().Msgf("retro_set_geometry")
geom := *(*C.struct_retro_game_geometry)(data)
Nan0.log.Debug().Msgf(">>> geometry change: %v", geom)
// some cores are eager to change resolution too many times
// in a small period of time, thus we have some debouncer here
Nan0.limiter(func() {
lw := Nan0.sys.av.geometry.base_width
lh := Nan0.sys.av.geometry.base_height
if lw != geom.base_width || lh != geom.base_height {
Nan0.sys.av.geometry = geom
if Nan0.OnSystemAvInfo != nil {
go Nan0.OnSystemAvInfo()
}
}
})
if isGeometryDifferent(geom) {
geometryChange(geom)
}
return true
case C.RETRO_ENVIRONMENT_SET_ROTATION:
setRotation((*(*uint)(data) % 4) * 90)
@ -876,3 +877,21 @@ func (d *limit) push(f func()) {
}
d.t = time.AfterFunc(d.d, f)
}
func geometryChange(geom C.struct_retro_game_geometry) {
Nan0.limiter(func() {
old := Nan0.sys.av.geometry
Nan0.sys.av.geometry = geom
if Nan0.OnSystemAvInfo != nil {
Nan0.log.Debug().Msgf(">>> geometry change %v -> %v", old, geom)
if Nan0.Aspect {
go Nan0.OnSystemAvInfo()
}
}
})
}
func isGeometryDifferent(geom C.struct_retro_game_geometry) bool {
return Nan0.sys.av.geometry.base_width != geom.base_width ||
Nan0.sys.av.geometry.base_height != geom.base_height
}

View file

@ -112,6 +112,31 @@ func (c *coordinator) HandleGameStart(rq api.StartGameRequest[com.Uid], w *Worke
r.SetApp(app)
m := media.NewWebRtcMediaPipe(w.conf.Encoder.Audio, w.conf.Encoder.Video, w.log)
// recreate the video encoder
app.VideoChangeCb(func() {
app.ViewportRecalculate()
m.VideoW, m.VideoH = app.ViewportSize()
m.VideoScale = app.Scale()
if m.IsInitialized() {
if err := m.Reinit(); err != nil {
c.log.Error().Err(err).Msgf("reinit fail")
}
}
data, err := api.Wrap(api.Out{T: uint8(api.AppVideoChange), Payload: api.AppVideoInfo{
W: m.VideoW,
H: m.VideoH,
A: app.AspectRatio(),
}})
if err != nil {
c.log.Error().Err(err).Msgf("wrap")
}
r.Send(data)
})
w.log.Info().Msgf("Starting the game: %v", rq.Game.Name)
if err := app.Load(game, w.conf.Worker.Library.BasePath); err != nil {
c.log.Error().Err(err).Msgf("couldn't load the game %v", game)
@ -120,7 +145,6 @@ func (c *coordinator) HandleGameStart(rq api.StartGameRequest[com.Uid], w *Worke
return api.EmptyPacket
}
m := media.NewWebRtcMediaPipe(w.conf.Encoder.Audio, w.conf.Encoder.Video, w.log)
m.AudioSrcHz = app.AudioSampleRate()
m.AudioFrame = w.conf.Encoder.Audio.Frame
m.VideoW, m.VideoH = app.ViewportSize()
@ -140,16 +164,6 @@ func (c *coordinator) HandleGameStart(rq api.StartGameRequest[com.Uid], w *Worke
m.SetPixFmt(app.PixFormat())
m.SetRot(app.Rotation())
// recreate the video encoder
app.VideoChangeCb(func() {
app.ViewportRecalculate()
m.VideoW, m.VideoH = app.ViewportSize()
m.VideoScale = app.Scale()
if err := m.Reinit(); err != nil {
c.log.Error().Err(err).Msgf("reinit fail")
}
})
r.BindAppMedia()
r.StartApp()
}
@ -159,7 +173,13 @@ func (c *coordinator) HandleGameStart(rq api.StartGameRequest[com.Uid], w *Worke
c.RegisterRoom(r.Id())
return api.Out{Payload: api.StartGameResponse{Room: api.Room{Rid: r.Id()}, Record: w.conf.Recording.Enabled}}
response := api.StartGameResponse{Room: api.Room{Rid: r.Id()}, Record: w.conf.Recording.Enabled}
if r.App().AspectEnabled() {
ww, hh := r.App().ViewportSize()
response.AV = &api.AppVideoInfo{W: ww, H: hh, A: r.App().AspectRatio()}
}
return api.Out{Payload: response}
}
// HandleTerminateSession handles cases when a user has been disconnected from the websocket of coordinator.

View file

@ -115,6 +115,8 @@ type WebrtcMediaPipe struct {
VideoW, VideoH int
VideoScale float64
initialized bool
// keep the old settings for reinit
oldPf uint32
oldRot uint
@ -144,6 +146,7 @@ func (wmp *WebrtcMediaPipe) Init() error {
return err
}
wmp.log.Debug().Msgf("%v", wmp.v.Info())
wmp.initialized = true
return nil
}
@ -188,6 +191,10 @@ func (wmp *WebrtcMediaPipe) ProcessVideo(v app.Video) []byte {
}
func (wmp *WebrtcMediaPipe) Reinit() error {
if !wmp.initialized {
return nil
}
wmp.v.Stop()
if err := wmp.initVideo(wmp.VideoW, wmp.VideoH, wmp.VideoScale, wmp.vConf); err != nil {
return err
@ -199,6 +206,7 @@ func (wmp *WebrtcMediaPipe) Reinit() error {
return nil
}
func (wmp *WebrtcMediaPipe) IsInitialized() bool { return wmp.initialized }
func (wmp *WebrtcMediaPipe) SetPixFmt(f uint32) { wmp.oldPf = f; wmp.v.SetPixFormat(f) }
func (wmp *WebrtcMediaPipe) SetVideoFlip(b bool) { wmp.oldFlip = b; wmp.v.SetFlip(b) }
func (wmp *WebrtcMediaPipe) SetRot(r uint) { wmp.oldRot = r; wmp.v.SetRot(r) }

View file

@ -81,6 +81,7 @@ func (r *Room[T]) Id() string { return r.id }
func (r *Room[T]) SetApp(app app.App) { r.app = app }
func (r *Room[T]) SetMedia(m MediaPipe) { r.media = m }
func (r *Room[T]) StartApp() { r.app.Start() }
func (r *Room[T]) Send(data []byte) { r.users.ForEach(func(u T) { u.SendData(data) }) }
func (r *Room[T]) Close() {
if r == nil || r.closed {

View file

@ -22,7 +22,7 @@ body {
display: flex;
overflow: hidden;
width: 556px;
width: 640px;
height: 286px;
position: absolute;
@ -65,6 +65,11 @@ body {
background-size: 100% 100%;
}
#controls-right {
position: absolute;
left: 70px;
}
#circle-pad-holder {
display: block;
@ -83,11 +88,10 @@ body {
}
#guide-txt {
color: #bababa;
color: #979797;
font-size: 8px;
top: 269px;
left: 30px;
width: 1000px;
left: 68px;
position: absolute;
user-select: none;
@ -166,7 +170,7 @@ body {
align-items: center;
justify-content: center;
width: 256px;
width: 320px;
height: 240px;
position: absolute;
top: 23px;
@ -416,11 +420,11 @@ body {
}
.game-screen {
width: 100%;
height: 102%; /* lol */
background-color: #222222;
position: absolute;
display: flex;
object-fit: contain;
width: inherit;
height: inherit;
background-color: #222222;
}
#menu-screen {
@ -428,7 +432,7 @@ body {
display: block;
overflow: hidden;
width: 256px;
width: 320px;
height: 240px;
background-image: url('/img/screen_background5.png');
@ -444,6 +448,7 @@ body {
height: 36px;
background-color: #FFCF9E;
opacity: 0.75;
mix-blend-mode: lighten;
top: 50%;
left: 0;
@ -459,7 +464,7 @@ body {
top: 102px; /* 240px - 36 / 2 */
left: 0;
z-index: 1;
/*z-index: 1;*/
}
@ -481,7 +486,7 @@ body {
left: 15px;
top: 7px;
width: 226px;
width: 288px;
height: 25px;
}

View file

@ -47,26 +47,28 @@
<div id="servers"></div>
<div id="guide-txt"><b>Arrows</b>(move),<b>ZXCVAS</b>(game ABXYLR),<b>1/2</b>(1st/2nd player),<b>Shift/Enter/K/L</b>(select/start/save/load),<b>F</b>(fullscreen),<b>share</b>(copy
sharelink to clipboard)
<div id="guide-txt">
<b>Arrows</b> (move), <b>ZXCVAS</b> (game ABXYLR), <b>1/2</b> (1st/2nd player), <b>Shift/Enter/K/L</b> (select/start/save/load), <b>F</b> (fullscreen), <b>share</b> (copy shared link to the clipboard)
</div>
<div id="btn-load" class="btn big hidden" value="load"></div>
<div id="btn-save" class="btn big hidden" value="save"></div>
<div id="btn-join" class="btn big" value="join"></div>
<div id="slider-playeridx" class="slidecontainer">
<span>player choice</span>
<input type="range" min="1" max="4" value="1" class="slider" id="playeridx" onkeydown="event.preventDefault()">
</div>
<div id="btn-quit" class="btn big" value="quit"></div>
<div id="btn-select" class="btn big" value="select"></div>
<div id="btn-start" class="btn big" value="start"></div>
<div id="color-button-holder">
<div id="btn-a" class="btn" value="a"></div>
<div id="btn-b" class="btn" value="b"></div>
<div id="btn-x" class="btn" value="x"></div>
<div id="btn-y" class="btn" value="y"></div>
<div id="controls-right">
<div id="btn-load" class="btn big hidden" value="load"></div>
<div id="btn-save" class="btn big hidden" value="save"></div>
<div id="btn-select" class="btn big" value="select"></div>
<div id="btn-start" class="btn big" value="start"></div>
<div id="color-button-holder">
<div id="btn-a" class="btn" value="a"></div>
<div id="btn-b" class="btn" value="b"></div>
<div id="btn-x" class="btn" value="x"></div>
<div id="btn-y" class="btn" value="y"></div>
</div>
</div>
<div id="btn-settings" class="btn" value="settings"></div>
@ -125,16 +127,16 @@
<script src="js/env.js?v=5"></script>
<script src="js/input/input.js?v=3"></script>
<script src="js/gameList.js?v=3"></script>
<script src="js/stream/stream.js?v=2"></script>
<script src="js/stream/stream.js?v=3"></script>
<script src="js/room.js?v=3"></script>
<script src="js/network/ajax.js?v=3"></script>
<script src="js/network/socket.js?v=4"></script>
<script src="js/network/webrtc.js?v=2"></script>
<script src="js/network/webrtc.js?v=3"></script>
<script src="js/recording.js?v=1"></script>
<script src="js/api/api.js?v=2"></script>
<script src="js/api/api.js?v=3"></script>
<script src="js/workerManager.js?v=1"></script>
<script src="js/stats/stats.js?v=1"></script>
<script src="js/controller.js?v=7"></script>
<script src="js/controller.js?v=8"></script>
<script src="js/input/keyboard.js?v=5"></script>
<script src="js/input/touch.js?v=3"></script>
<script src="js/input/joystick.js?v=3"></script>

View file

@ -21,6 +21,8 @@ const api = (() => {
GAME_RECORDING: 110,
GET_WORKER_LIST: 111,
GAME_ERROR_NO_FREE_SLOTS: 112,
APP_VIDEO_CHANGE: 150,
});
const packet = (type, payload, id) => {
@ -31,18 +33,21 @@ const api = (() => {
socket.send(packet);
};
const decodeBytes = (b) => String.fromCharCode.apply(null, new Uint8Array(b))
return Object.freeze({
endpoint: endpoints,
decode: (b) => JSON.parse(decodeBytes(b)),
server:
Object.freeze({
{
initWebrtc: () => packet(endpoints.INIT_WEBRTC),
sendIceCandidate: (candidate) => packet(endpoints.ICE_CANDIDATE, btoa(JSON.stringify(candidate))),
sendSdp: (sdp) => packet(endpoints.ANSWER, btoa(JSON.stringify(sdp))),
latencyCheck: (id, list) => packet(endpoints.LATENCY_CHECK, list, id),
getWorkerList: () => packet(endpoints.GET_WORKER_LIST),
}),
},
game:
Object.freeze({
{
load: () => packet(endpoints.GAME_LOAD),
save: () => packet(endpoints.GAME_SAVE),
setPlayerIndex: (i) => packet(endpoints.GAME_SET_PLAYER_INDEX, i),
@ -60,6 +65,6 @@ const api = (() => {
user: userName,
}),
quit: (roomId) => packet(endpoints.GAME_QUIT, {room_id: roomId}),
})
}
})
})(socket);

View file

@ -153,8 +153,8 @@
const saveGame = utils.debounce(() => api.game.save(), 1000);
const loadGame = utils.debounce(() => api.game.load(), 1000);
const onMessage = (message) => {
const {id, t, p: payload} = message;
const onMessage = (m) => {
const {id, t, p: payload} = m;
switch (t) {
case api.endpoint.INIT:
event.pub(WEBRTC_NEW_CONNECTION, payload);
@ -166,7 +166,10 @@
event.pub(WEBRTC_ICE_CANDIDATE_RECEIVED, {candidate: payload});
break;
case api.endpoint.GAME_START:
event.pub(GAME_ROOM_AVAILABLE, {roomId: payload});
if (payload.av) {
event.pub(APP_VIDEO_CHANGED, payload.av)
}
event.pub(GAME_ROOM_AVAILABLE, {roomId: payload.roomId});
break;
case api.endpoint.GAME_SAVE:
event.pub(GAME_SAVED);
@ -189,6 +192,9 @@
case api.endpoint.GAME_ERROR_NO_FREE_SLOTS:
event.pub(GAME_ERROR_NO_FREE_SLOTS);
break;
case api.endpoint.APP_VIDEO_CHANGE:
event.pub(APP_VIDEO_CHANGED, {...payload})
break;
}
}
@ -429,6 +435,7 @@
event.sub(GAME_ERROR_NO_FREE_SLOTS, () => message.show("No free slots :(", 2500));
event.sub(WEBRTC_NEW_CONNECTION, (data) => {
workerManager.whoami(data.wid);
webrtc.onData = (x) => onMessage(api.decode(x.data))
webrtc.start(data.ice);
api.server.initWebrtc()
gameList.set(data.games);
@ -438,7 +445,6 @@
event.sub(WEBRTC_SDP_OFFER, (data) => webrtc.setRemoteDescription(data.sdp, stream.video.el()));
event.sub(WEBRTC_ICE_CANDIDATE_RECEIVED, (data) => webrtc.addCandidate(data.candidate));
event.sub(WEBRTC_ICE_CANDIDATES_FLUSH, () => webrtc.flushCandidates());
// event.sub(MEDIA_STREAM_READY, () => rtcp.start());
event.sub(WEBRTC_CONNECTION_READY, onConnectionReady);
event.sub(WEBRTC_CONNECTION_CLOSED, () => {
input.poll.disable();

View file

@ -101,3 +101,5 @@ const SETTINGS_CLOSED = 'settingsClosed';
const RECORDING_TOGGLED = 'recordingToggle'
const RECORDING_STATUS_CHANGED = 'recordingStatusChanged'
const APP_VIDEO_CHANGED = 'appVideoChanged'

View file

@ -12,16 +12,16 @@
*/
const webrtc = (() => {
let connection;
let inputChannel;
let dataChannel;
let mediaStream;
let candidates = Array();
let candidates = [];
let isAnswered = false;
let isFlushing = false;
let connected = false;
let inputReady = false;
let onMessage;
let onData;
const start = (iceservers) => {
log.info('[rtc] <- ICE servers', iceservers);
@ -31,16 +31,16 @@ const webrtc = (() => {
connection.ondatachannel = e => {
log.debug('[rtc] ondatachannel', e.channel.label)
inputChannel = e.channel;
inputChannel.onopen = () => {
dataChannel = e.channel;
dataChannel.onopen = () => {
log.info('[rtc] the input channel has been opened');
inputReady = true;
event.pub(WEBRTC_CONNECTION_READY)
};
if (onMessage) {
inputChannel.onmessage = onMessage;
if (onData) {
dataChannel.onmessage = onData;
}
inputChannel.onclose = () => log.info('[rtc] the input channel has been closed');
dataChannel.onclose = () => log.info('[rtc] the input channel has been closed');
}
connection.oniceconnectionstatechange = ice.onIceConnectionStateChange;
connection.onicegatheringstatechange = ice.onIceStateChange;
@ -62,9 +62,9 @@ const webrtc = (() => {
connection.close();
connection = null;
}
if (inputChannel) {
inputChannel.close();
inputChannel = null;
if (dataChannel) {
dataChannel.close();
dataChannel = null;
}
candidates = Array();
log.info('[rtc] WebRTC has been closed');
@ -173,10 +173,13 @@ const webrtc = (() => {
// return false
// }
// },
input: (data) => inputChannel.send(data),
input: (data) => dataChannel.send(data),
isConnected: () => connected,
isInputReady: () => inputReady,
getConnection: () => connection,
stop,
set onData(fn) {
onData = fn
}
}
})(event, log);

View file

@ -16,6 +16,9 @@ const stream = (() => {
state = {
screen: screen,
timerId: null,
w: 0,
h: 0,
aspect: 4/3
};
const mute = (mute) => screen.muted = mute
@ -82,6 +85,25 @@ const stream = (() => {
useCustomScreen(options.mirrorMode === 'mirror');
}, false);
screen.addEventListener('fullscreenchange', () => {
const fullscreen = document.fullscreenElement
screen.style.padding = '0'
if (fullscreen) {
const dw = (window.innerWidth - fullscreen.clientHeight * state.aspect) / 2
screen.style.padding = `0 ${dw}px`
// chrome bug
setTimeout(() => {
const dw = (window.innerHeight - fullscreen.clientHeight * state.aspect) / 2
screen.style.padding = `0 ${dw}px`
}, 1)
}
// !to flipped
})
const useCustomScreen = (use) => {
if (use) {
if (screen.paused || screen.ended) return;
@ -95,13 +117,15 @@ const stream = (() => {
canvas.setAttribute('width', screen.videoWidth);
canvas.setAttribute('height', screen.videoHeight);
canvas.style['image-rendering'] = 'pixelated';
canvas.style.width = '100%'
canvas.style.height = '100%'
canvas.classList.add('game-screen');
// stretch depending on the video orientation
// portrait -- vertically, landscape -- horizontally
const isPortrait = screen.videoWidth < screen.videoHeight;
canvas.style.width = isPortrait ? 'auto' : canvas.style.width;
canvas.style.height = isPortrait ? canvas.style.height : 'auto';
// canvas.style.height = isPortrait ? canvas.style.height : 'auto';
let surface = canvas.getContext('2d');
screen.parentNode.insertBefore(canvas, screen.nextSibling);
@ -135,6 +159,27 @@ const stream = (() => {
}
});
const fit = 'contain'
event.sub(APP_VIDEO_CHANGED, (payload) => {
const {w, h, a} = payload
state.aspect = a
const a2 = w / h
const attr = a.toFixed(6) !== a2.toFixed(6) ? 'fill' : fit
state.screen.style['object-fit'] = attr
state.h = payload.h
state.w = Math.floor(payload.h * payload.a)
// payload.a > 0 && (state.aspect = payload.a)
state.screen.setAttribute('width', payload.w)
state.screen.setAttribute('height', payload.h)
state.screen.style.aspectRatio = state.aspect
})
return {
audio: {mute},
video: {toggleFullscreen, el: getVideoEl},
@ -144,4 +189,4 @@ const stream = (() => {
init
}
}
)(env, gui, log, opts, settings);
)(env, event, gui, log, opts, settings);