mirror of
https://github.com/giongto35/cloud-game.git
synced 2026-01-23 02:34:42 +00:00
Add keyboard and mouse support
Keyboard and mouse controls will now work if you use the kbMouseSupport parameter in the config for Libretro cores. Be aware that capturing mouse and keyboard controls properly is only possible in fullscreen mode. Note: In the case of DOSBox, a virtual filesystem handler is not yet implemented, thus each game state will be shared between all rooms (DOS game instances) of CloudRetro.
This commit is contained in:
parent
af8569a605
commit
7ee98c1b03
38 changed files with 1561 additions and 543 deletions
|
|
@ -12,8 +12,9 @@ type (
|
|||
PlayerIndex int `json:"player_index"`
|
||||
}
|
||||
GameStartUserResponse struct {
|
||||
RoomId string `json:"roomId"`
|
||||
Av *AppVideoInfo `json:"av"`
|
||||
RoomId string `json:"roomId"`
|
||||
Av *AppVideoInfo `json:"av"`
|
||||
KbMouse bool `json:"kb_mouse"`
|
||||
}
|
||||
IceServer struct {
|
||||
Urls string `json:"urls,omitempty"`
|
||||
|
|
|
|||
|
|
@ -33,8 +33,9 @@ type (
|
|||
}
|
||||
StartGameResponse struct {
|
||||
Room
|
||||
AV *AppVideoInfo `json:"av"`
|
||||
Record bool
|
||||
AV *AppVideoInfo `json:"av"`
|
||||
Record bool `json:"record"`
|
||||
KbMouse bool `json:"kb_mouse"`
|
||||
}
|
||||
RecordGameRequest[T Id] struct {
|
||||
StatefulRoom[T]
|
||||
|
|
|
|||
|
|
@ -185,11 +185,13 @@ emulator:
|
|||
# - isGlAllowed (bool)
|
||||
# - usesLibCo (bool)
|
||||
# - hasMultitap (bool) -- (removed)
|
||||
# - coreAspectRatio (bool) -- correct the aspect ratio on the client with the info from the core.
|
||||
# - coreAspectRatio (bool) -- (deprecated) correct the aspect ratio on the client with the info from the core.
|
||||
# - hid (map[int][]int)
|
||||
# A list of device IDs to bind to the input ports.
|
||||
# Can be seen in human readable form in the console when worker.debug is enabled.
|
||||
# Some cores allow binding multiple devices to a single port (DosBox), but typically,
|
||||
# you should bind just one device to one port.
|
||||
# - kbMouseSupport (bool) -- (temp) a flag if the core needs the keyboard and mouse on the client
|
||||
# - vfr (bool)
|
||||
# (experimental)
|
||||
# Enable variable frame rate only for cores that can't produce a constant frame rate.
|
||||
|
|
@ -213,7 +215,6 @@ 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,7 +228,6 @@ emulator:
|
|||
# https://docs.libretro.com/library/fbneo/
|
||||
mame:
|
||||
lib: fbneo_libretro
|
||||
coreAspectRatio: true
|
||||
roms: [ "zip" ]
|
||||
nes:
|
||||
lib: nestopia_libretro
|
||||
|
|
@ -280,7 +280,7 @@ encoder:
|
|||
# see: https://trac.ffmpeg.org/wiki/Encode/H.264
|
||||
h264:
|
||||
# Constant Rate Factor (CRF) 0-51 (default: 23)
|
||||
crf: 26
|
||||
crf: 23
|
||||
# ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow, placebo
|
||||
preset: superfast
|
||||
# baseline, main, high, high10, high422, high444
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ type LibretroCoreConfig struct {
|
|||
Height int
|
||||
Hid map[int][]int
|
||||
IsGlAllowed bool
|
||||
KbMouseSupport bool
|
||||
Lib string
|
||||
Options map[string]string
|
||||
Options4rom map[string]map[string]string // <(^_^)>
|
||||
|
|
|
|||
|
|
@ -37,6 +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(av *api.AppVideoInfo) {
|
||||
u.Notify(api.StartGame, api.GameStartUserResponse{RoomId: u.w.RoomId, Av: av})
|
||||
func (u *User) StartGame(av *api.AppVideoInfo, kbMouse bool) {
|
||||
u.Notify(api.StartGame, api.GameStartUserResponse{RoomId: u.w.RoomId, Av: av, KbMouse: kbMouse})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(startGameResp.AV)
|
||||
u.StartGame(startGameResp.AV, startGameResp.KbMouse)
|
||||
|
||||
// send back recording status
|
||||
if conf.Recording.Enabled && rq.Record {
|
||||
|
|
|
|||
|
|
@ -81,11 +81,15 @@ 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 [data] channel (in and out)
|
||||
if err = p.addDataChannel("data"); err != nil {
|
||||
err = p.AddChannel("data", func(data []byte) {
|
||||
if len(data) == 0 || p.OnMessage == nil {
|
||||
return
|
||||
}
|
||||
p.OnMessage(data)
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p.log.Debug().Msg("Added [data] chan")
|
||||
|
||||
p.conn.OnICEConnectionStateChange(p.handleICEState(func() { p.log.Info().Msg("Connected") }))
|
||||
// Stream provider supposes to send offer
|
||||
|
|
@ -221,6 +225,19 @@ func (p *Peer) AddCandidate(candidate string, decoder Decoder) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (p *Peer) AddChannel(label string, onMessage func([]byte)) error {
|
||||
ch, err := p.addDataChannel(label)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if label == "data" {
|
||||
p.d = ch
|
||||
}
|
||||
ch.OnMessage(func(m webrtc.DataChannelMessage) { onMessage(m.Data) })
|
||||
p.log.Debug().Msgf("Added [%v] chan", label)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Peer) Disconnect() {
|
||||
if p.conn == nil {
|
||||
return
|
||||
|
|
@ -232,29 +249,19 @@ func (p *Peer) Disconnect() {
|
|||
p.log.Debug().Msg("WebRTC stop")
|
||||
}
|
||||
|
||||
// addDataChannel creates a new WebRTC data channel for user input.
|
||||
// addDataChannel creates new WebRTC data channel.
|
||||
// Default params -- ordered: true, negotiated: false.
|
||||
func (p *Peer) addDataChannel(label string) error {
|
||||
func (p *Peer) addDataChannel(label string) (*webrtc.DataChannel, error) {
|
||||
ch, err := p.conn.CreateDataChannel(label, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
ch.OnOpen(func() {
|
||||
p.log.Debug().Str("label", ch.Label()).Uint16("id", *ch.ID()).
|
||||
Msg("Data channel [input] opened")
|
||||
p.log.Debug().Uint16("id", *ch.ID()).Msgf("Data channel [%v] opened", ch.Label())
|
||||
})
|
||||
ch.OnError(p.logx)
|
||||
ch.OnMessage(func(m webrtc.DataChannelMessage) {
|
||||
if len(m.Data) == 0 {
|
||||
return
|
||||
}
|
||||
if p.OnMessage != nil {
|
||||
p.OnMessage(m.Data)
|
||||
}
|
||||
})
|
||||
p.d = ch
|
||||
ch.OnClose(func() { p.log.Debug().Msg("Data channel [input] has been closed") })
|
||||
return nil
|
||||
ch.OnClose(func() { p.log.Debug().Msgf("Data channel [%v] has been closed", ch.Label()) })
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func (p *Peer) logx(err error) { p.log.Error().Err(err) }
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ type App interface {
|
|||
SetAudioCb(func(Audio))
|
||||
SetVideoCb(func(Video))
|
||||
SetDataCb(func([]byte))
|
||||
SendControl(port int, data []byte)
|
||||
Input(port int, device byte, data []byte)
|
||||
KbMouseSupport() bool
|
||||
}
|
||||
|
||||
type Audio struct {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,12 @@ type Manager struct {
|
|||
log *logger.Logger
|
||||
}
|
||||
|
||||
const (
|
||||
RetroPad = libretro.RetroPad
|
||||
Keyboard = libretro.Keyboard
|
||||
Mouse = libretro.Mouse
|
||||
)
|
||||
|
||||
type ModName string
|
||||
|
||||
const Libretro ModName = "libretro"
|
||||
|
|
|
|||
|
|
@ -79,15 +79,16 @@ 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() }
|
||||
func (c *Caged) ViewportSize() (int, int) { return c.base.ViewportSize() }
|
||||
func (c *Caged) Scale() float64 { return c.Emulator.Scale() }
|
||||
func (c *Caged) SendControl(port int, data []byte) { c.base.Input(port, data) }
|
||||
func (c *Caged) Start() { go c.Emulator.Start() }
|
||||
func (c *Caged) SetSaveOnClose(v bool) { c.base.SaveOnClose = v }
|
||||
func (c *Caged) SetSessionId(name string) { c.base.SetSessionId(name) }
|
||||
func (c *Caged) Close() { c.Emulator.Close() }
|
||||
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() }
|
||||
func (c *Caged) ViewportSize() (int, int) { return c.base.ViewportSize() }
|
||||
func (c *Caged) Scale() float64 { return c.Emulator.Scale() }
|
||||
func (c *Caged) Input(p int, d byte, data []byte) { c.base.Input(p, d, data) }
|
||||
func (c *Caged) KbMouseSupport() bool { return c.base.KbMouseSupport() }
|
||||
func (c *Caged) Start() { go c.Emulator.Start() }
|
||||
func (c *Caged) SetSaveOnClose(v bool) { c.base.SaveOnClose = v }
|
||||
func (c *Caged) SetSessionId(name string) { c.base.SetSessionId(name) }
|
||||
func (c *Caged) Close() { c.Emulator.Close() }
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import (
|
|||
"fmt"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
|
|
@ -44,7 +43,7 @@ type Emulator interface {
|
|||
// Close will be called when the game is done
|
||||
Close()
|
||||
// Input passes input to the emulator
|
||||
Input(player int, data []byte)
|
||||
Input(player int, device byte, data []byte)
|
||||
// Scale returns set video scale factor
|
||||
Scale() float64
|
||||
}
|
||||
|
|
@ -52,7 +51,6 @@ type Emulator interface {
|
|||
type Frontend struct {
|
||||
conf config.Emulator
|
||||
done chan struct{}
|
||||
input InputState
|
||||
log *logger.Logger
|
||||
nano *nanoarch.Nanoarch
|
||||
onAudio func(app.Audio)
|
||||
|
|
@ -70,21 +68,12 @@ type Frontend struct {
|
|||
SaveOnClose bool
|
||||
}
|
||||
|
||||
// InputState stores full controller state.
|
||||
// It consists of:
|
||||
// - uint16 button values
|
||||
// - int16 analog stick values
|
||||
type (
|
||||
InputState [maxPort]State
|
||||
State struct {
|
||||
keys uint32
|
||||
axes [dpadAxes]int32
|
||||
}
|
||||
)
|
||||
type Device byte
|
||||
|
||||
const (
|
||||
maxPort = 4
|
||||
dpadAxes = 4
|
||||
RetroPad = Device(nanoarch.RetroPad)
|
||||
Keyboard = Device(nanoarch.Keyboard)
|
||||
Mouse = Device(nanoarch.Mouse)
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
@ -129,7 +118,6 @@ func NewFrontend(conf config.Emulator, log *logger.Logger) (*Frontend, error) {
|
|||
f := &Frontend{
|
||||
conf: conf,
|
||||
done: make(chan struct{}),
|
||||
input: NewGameSessionInput(),
|
||||
log: log,
|
||||
onAudio: noAudio,
|
||||
onData: noData,
|
||||
|
|
@ -162,6 +150,7 @@ func (f *Frontend) LoadCore(emu string) {
|
|||
Options4rom: conf.Options4rom,
|
||||
UsesLibCo: conf.UsesLibCo,
|
||||
CoreAspectRatio: conf.CoreAspectRatio,
|
||||
KbMouseSupport: conf.KbMouseSupport,
|
||||
}
|
||||
f.mu.Lock()
|
||||
scale := 1.0
|
||||
|
|
@ -227,8 +216,6 @@ func (f *Frontend) linkNano(nano *nanoarch.Nanoarch) {
|
|||
}
|
||||
f.nano.WaitReady() // start only when nano is available
|
||||
|
||||
f.nano.OnKeyPress = f.input.isKeyPressed
|
||||
f.nano.OnDpad = f.input.isDpadTouched
|
||||
f.nano.OnVideo = f.handleVideo
|
||||
f.nano.OnAudio = f.handleAudio
|
||||
f.nano.OnDup = f.handleDup
|
||||
|
|
@ -300,8 +287,8 @@ func (f *Frontend) Flipped() bool { return f.nano.IsGL() }
|
|||
func (f *Frontend) FrameSize() (int, int) { return f.nano.BaseWidth(), f.nano.BaseHeight() }
|
||||
func (f *Frontend) HasSave() bool { return os.Exists(f.HashPath()) }
|
||||
func (f *Frontend) HashPath() string { return f.storage.GetSavePath() }
|
||||
func (f *Frontend) Input(player int, data []byte) { f.input.setInput(player, data) }
|
||||
func (f *Frontend) IsPortrait() bool { return f.nano.IsPortrait() }
|
||||
func (f *Frontend) KbMouseSupport() bool { return f.nano.KbMouseSupport() }
|
||||
func (f *Frontend) LoadGame(path string) error { return f.nano.LoadGame(path) }
|
||||
func (f *Frontend) PixFormat() uint32 { return f.nano.Video.PixFmt.C }
|
||||
func (f *Frontend) RestoreGameState() error { return f.Load() }
|
||||
|
|
@ -318,6 +305,17 @@ func (f *Frontend) Tick() { f.mu.Lock(); f.nano.Run(); f
|
|||
func (f *Frontend) ViewportRecalculate() { f.mu.Lock(); f.vw, f.vh = f.ViewportCalc(); f.mu.Unlock() }
|
||||
func (f *Frontend) ViewportSize() (int, int) { return f.vw, f.vh }
|
||||
|
||||
func (f *Frontend) Input(port int, device byte, data []byte) {
|
||||
switch Device(device) {
|
||||
case RetroPad:
|
||||
f.nano.InputRetropad(port, data)
|
||||
case Keyboard:
|
||||
f.nano.InputKeyboard(port, data)
|
||||
case Mouse:
|
||||
f.nano.InputMouse(port, data)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Frontend) ViewportCalc() (nw int, nh int) {
|
||||
w, h := f.FrameSize()
|
||||
nw, nh = w, h
|
||||
|
|
@ -408,24 +406,3 @@ func (f *Frontend) autosave(periodSec int) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func NewGameSessionInput() InputState { return [maxPort]State{} }
|
||||
|
||||
// setInput sets input state for some player in a game session.
|
||||
func (s *InputState) setInput(player int, data []byte) {
|
||||
atomic.StoreUint32(&s[player].keys, uint32(uint16(data[1])<<8+uint16(data[0])))
|
||||
for i, axes := 0, len(data); i < dpadAxes && i<<1+3 < axes; i++ {
|
||||
axis := i<<1 + 2
|
||||
atomic.StoreInt32(&s[player].axes[i], int32(data[axis+1])<<8+int32(data[axis]))
|
||||
}
|
||||
}
|
||||
|
||||
// isKeyPressed checks if some button is pressed by any player.
|
||||
func (s *InputState) isKeyPressed(port uint, key int) int {
|
||||
return int((atomic.LoadUint32(&s[port].keys) >> uint(key)) & 1)
|
||||
}
|
||||
|
||||
// isDpadTouched checks if D-pad is used by any player.
|
||||
func (s *InputState) isDpadTouched(port uint, axis uint) (shift int16) {
|
||||
return int16(atomic.LoadInt32(&s[port].axes[axis]))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,7 +86,6 @@ func EmulatorMock(room string, system string) *TestFrontend {
|
|||
Path: os.TempDir(),
|
||||
MainSave: room,
|
||||
},
|
||||
input: NewGameSessionInput(),
|
||||
done: make(chan struct{}),
|
||||
th: conf.Emulator.Threads,
|
||||
log: l2,
|
||||
|
|
@ -340,20 +339,6 @@ func TestStateConcurrency(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestConcurrentInput(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
state := NewGameSessionInput()
|
||||
events := 1000
|
||||
wg.Add(2 * events)
|
||||
|
||||
for range events {
|
||||
player := rand.IntN(maxPort)
|
||||
go func() { state.setInput(player, []byte{0, 1}); wg.Done() }()
|
||||
go func() { state.isKeyPressed(uint(player), 100); wg.Done() }()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestStartStop(t *testing.T) {
|
||||
f1 := DefaultFrontend("sushi", sushi.system, sushi.rom)
|
||||
go f1.Start()
|
||||
|
|
|
|||
150
pkg/worker/caged/libretro/nanoarch/input.go
Normal file
150
pkg/worker/caged/libretro/nanoarch/input.go
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
package nanoarch
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
//#include <stdint.h>
|
||||
//#include "libretro.h"
|
||||
import "C"
|
||||
|
||||
const (
|
||||
Released C.int16_t = iota
|
||||
Pressed
|
||||
)
|
||||
|
||||
const RetrokLast = int(C.RETROK_LAST)
|
||||
|
||||
// InputState stores full controller state.
|
||||
// It consists of:
|
||||
// - uint16 button values
|
||||
// - int16 analog stick values
|
||||
type InputState [maxPort]RetroPadState
|
||||
|
||||
type (
|
||||
RetroPadState struct {
|
||||
keys uint32
|
||||
axes [dpadAxes]int32
|
||||
}
|
||||
KeyboardState struct {
|
||||
keys [RetrokLast]byte
|
||||
mod uint16
|
||||
mu sync.Mutex
|
||||
}
|
||||
MouseState struct {
|
||||
dx, dy atomic.Int32
|
||||
buttons atomic.Int32
|
||||
}
|
||||
)
|
||||
|
||||
type MouseBtnState int32
|
||||
|
||||
type Device byte
|
||||
|
||||
const (
|
||||
RetroPad Device = iota
|
||||
Keyboard
|
||||
Mouse
|
||||
)
|
||||
|
||||
const (
|
||||
MouseMove = iota
|
||||
MouseButton
|
||||
)
|
||||
|
||||
const (
|
||||
MouseLeft MouseBtnState = 1 << iota
|
||||
MouseRight
|
||||
MouseMiddle
|
||||
)
|
||||
|
||||
const (
|
||||
maxPort = 4
|
||||
dpadAxes = 4
|
||||
)
|
||||
|
||||
// Input sets input state for some player in a game session.
|
||||
func (s *InputState) Input(port int, data []byte) {
|
||||
atomic.StoreUint32(&s[port].keys, uint32(uint16(data[1])<<8+uint16(data[0])))
|
||||
for i, axes := 0, len(data); i < dpadAxes && i<<1+3 < axes; i++ {
|
||||
axis := i<<1 + 2
|
||||
atomic.StoreInt32(&s[port].axes[i], int32(data[axis+1])<<8+int32(data[axis]))
|
||||
}
|
||||
}
|
||||
|
||||
// IsKeyPressed checks if some button is pressed by any player.
|
||||
func (s *InputState) IsKeyPressed(port uint, key int) C.int16_t {
|
||||
return C.int16_t((atomic.LoadUint32(&s[port].keys) >> uint(key)) & 1)
|
||||
}
|
||||
|
||||
// IsDpadTouched checks if D-pad is used by any player.
|
||||
func (s *InputState) IsDpadTouched(port uint, axis uint) (shift C.int16_t) {
|
||||
return C.int16_t(atomic.LoadInt32(&s[port].axes[axis]))
|
||||
}
|
||||
|
||||
// SetKey sets keyboard state.
|
||||
//
|
||||
// 0 1 2 3 4 5 6
|
||||
// [ KEY ] P MOD
|
||||
//
|
||||
// KEY contains Libretro code of the keyboard key (4 bytes).
|
||||
// P contains 0 or 1 if the key is pressed (1 byte).
|
||||
// MOD contains bitmask for Alt | Ctrl | Meta | Shift keys press state (2 bytes).
|
||||
//
|
||||
// Returns decoded state from the input bytes.
|
||||
func (ks *KeyboardState) SetKey(data []byte) (pressed bool, key uint, mod uint16) {
|
||||
if len(data) != 7 {
|
||||
return
|
||||
}
|
||||
|
||||
press := data[4]
|
||||
pressed = press == 1
|
||||
key = uint(binary.BigEndian.Uint32(data))
|
||||
mod = binary.BigEndian.Uint16(data[5:])
|
||||
ks.mu.Lock()
|
||||
ks.keys[key] = press
|
||||
ks.mod = mod
|
||||
ks.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
func (ks *KeyboardState) Pressed(key uint) C.int16_t {
|
||||
ks.mu.Lock()
|
||||
press := ks.keys[key]
|
||||
ks.mu.Unlock()
|
||||
if press == 1 {
|
||||
return Pressed
|
||||
}
|
||||
return Released
|
||||
}
|
||||
|
||||
// ShiftPos sets mouse relative position state.
|
||||
//
|
||||
// 0 1 2 3
|
||||
// [dx] [dy]
|
||||
//
|
||||
// dx and dy are relative mouse coordinates
|
||||
func (ms *MouseState) ShiftPos(data []byte) {
|
||||
if len(data) != 4 {
|
||||
return
|
||||
}
|
||||
dxy := binary.BigEndian.Uint32(data)
|
||||
ms.dx.Add(int32(int16(dxy >> 16)))
|
||||
ms.dy.Add(int32(int16(dxy)))
|
||||
}
|
||||
|
||||
func (ms *MouseState) PopX() C.int16_t { return C.int16_t(ms.dx.Swap(0)) }
|
||||
func (ms *MouseState) PopY() C.int16_t { return C.int16_t(ms.dy.Swap(0)) }
|
||||
|
||||
// SetButtons sets the state MouseBtnState of mouse buttons.
|
||||
func (ms *MouseState) SetButtons(data byte) { ms.buttons.Store(int32(data)) }
|
||||
|
||||
func (ms *MouseState) Buttons() (l, r, m bool) {
|
||||
mbs := MouseBtnState(ms.buttons.Load())
|
||||
l = mbs&MouseLeft != 0
|
||||
r = mbs&MouseRight != 0
|
||||
m = mbs&MouseMiddle != 0
|
||||
return
|
||||
}
|
||||
93
pkg/worker/caged/libretro/nanoarch/input_test.go
Normal file
93
pkg/worker/caged/libretro/nanoarch/input_test.go
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
package nanoarch
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConcurrentInput(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
state := InputState{}
|
||||
events := 1000
|
||||
wg.Add(2 * events)
|
||||
|
||||
for i := 0; i < events; i++ {
|
||||
player := rand.Intn(maxPort)
|
||||
go func() { state.Input(player, []byte{0, 1}); wg.Done() }()
|
||||
go func() { state.IsKeyPressed(uint(player), 100); wg.Done() }()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestMousePos(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dx int16
|
||||
dy int16
|
||||
rx int16
|
||||
ry int16
|
||||
b func(dx, dy int16) []byte
|
||||
}{
|
||||
{name: "normal", dx: -10123, dy: 5678, rx: -10123, ry: 5678, b: func(dx, dy int16) []byte {
|
||||
data := []byte{0, 0, 0, 0}
|
||||
binary.BigEndian.PutUint16(data, uint16(dx))
|
||||
binary.BigEndian.PutUint16(data[2:], uint16(dy))
|
||||
return data
|
||||
}},
|
||||
{name: "wrong endian", dx: -1234, dy: 5678, rx: 12027, ry: 11798, b: func(dx, dy int16) []byte {
|
||||
data := []byte{0, 0, 0, 0}
|
||||
binary.LittleEndian.PutUint16(data, uint16(dx))
|
||||
binary.LittleEndian.PutUint16(data[2:], uint16(dy))
|
||||
return data
|
||||
}},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
data := test.b(test.dx, test.dy)
|
||||
|
||||
ms := MouseState{}
|
||||
ms.ShiftPos(data)
|
||||
|
||||
x := int16(ms.PopX())
|
||||
y := int16(ms.PopY())
|
||||
|
||||
if x != test.rx || y != test.ry {
|
||||
t.Errorf("invalid state, %v = %v, %v = %v", test.rx, x, test.ry, y)
|
||||
}
|
||||
|
||||
if ms.dx.Load() != 0 || ms.dy.Load() != 0 {
|
||||
t.Errorf("coordinates weren't cleared")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMouseButtons(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data byte
|
||||
l bool
|
||||
r bool
|
||||
m bool
|
||||
}{
|
||||
{name: "l+r+m+", data: 1 + 2 + 4, l: true, r: true, m: true},
|
||||
{name: "l-r-m-", data: 0},
|
||||
{name: "l-r+m-", data: 2, r: true},
|
||||
{name: "l+r-m+", data: 1 + 4, l: true, m: true},
|
||||
}
|
||||
|
||||
ms := MouseState{}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ms.SetButtons(test.data)
|
||||
l, r, m := ms.Buttons()
|
||||
if l != test.l || r != test.r || m != test.m {
|
||||
t.Errorf("wrong button state: %v -> %v, %v, %v", test.data, l, r, m)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -127,6 +127,10 @@ void bridge_clear_all_thread_waits_cb(void *data) {
|
|||
*(retro_environment_t *)data = clear_all_thread_waits_cb;
|
||||
}
|
||||
|
||||
void bridge_retro_keyboard_callback(void *cb, bool down, unsigned keycode, uint32_t character, uint16_t keyModifiers) {
|
||||
(*(retro_keyboard_event_t *) cb)(down, keycode, character, keyModifiers);
|
||||
}
|
||||
|
||||
bool core_environment_cgo(unsigned cmd, void *data) {
|
||||
bool coreEnvironment(unsigned, void *);
|
||||
return coreEnvironment(cmd, data);
|
||||
|
|
|
|||
|
|
@ -28,13 +28,6 @@ import (
|
|||
*/
|
||||
import "C"
|
||||
|
||||
const lastKey = int(C.RETRO_DEVICE_ID_JOYPAD_R3)
|
||||
|
||||
const KeyPressed = 1
|
||||
const KeyReleased = 0
|
||||
|
||||
const MaxPort int = 4
|
||||
|
||||
var (
|
||||
RGBA5551 = PixFmt{C: 0, BPP: 2} // BIT_FORMAT_SHORT_5_5_5_1 has 5 bits R, 5 bits G, 5 bits B, 1 bit alpha
|
||||
RGBA8888Rev = PixFmt{C: 1, BPP: 4} // BIT_FORMAT_INT_8_8_8_8_REV has 8 bits R, 8 bits G, 8 bits B, 8 bit alpha
|
||||
|
|
@ -43,6 +36,12 @@ var (
|
|||
|
||||
type Nanoarch struct {
|
||||
Handlers
|
||||
|
||||
keyboard KeyboardState
|
||||
mouse MouseState
|
||||
retropad InputState
|
||||
|
||||
keyboardCb *C.struct_retro_keyboard_callback
|
||||
LastFrameTime int64
|
||||
LibCo bool
|
||||
meta Metadata
|
||||
|
|
@ -77,8 +76,6 @@ type Nanoarch struct {
|
|||
}
|
||||
|
||||
type Handlers struct {
|
||||
OnDpad func(port uint, axis uint) (shift int16)
|
||||
OnKeyPress func(port uint, key int) int
|
||||
OnAudio func(ptr unsafe.Pointer, frames int)
|
||||
OnVideo func(data []byte, delta int32, fi FrameInfo)
|
||||
OnDup func()
|
||||
|
|
@ -103,6 +100,7 @@ type Metadata struct {
|
|||
Hacks []string
|
||||
Hid map[int][]int
|
||||
CoreAspectRatio bool
|
||||
KbMouseSupport bool
|
||||
}
|
||||
|
||||
type PixFmt struct {
|
||||
|
|
@ -129,11 +127,9 @@ var Nan0 = Nanoarch{
|
|||
Stopped: atomic.Bool{},
|
||||
limiter: func(fn func()) { fn() },
|
||||
Handlers: Handlers{
|
||||
OnDpad: func(uint, uint) int16 { return 0 },
|
||||
OnKeyPress: func(uint, int) int { return 0 },
|
||||
OnAudio: func(unsafe.Pointer, int) {},
|
||||
OnVideo: func([]byte, int32, FrameInfo) {},
|
||||
OnDup: func() {},
|
||||
OnAudio: func(unsafe.Pointer, int) {},
|
||||
OnVideo: func([]byte, int32, FrameInfo) {},
|
||||
OnDup: func() {},
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -153,6 +149,7 @@ func (n *Nanoarch) AspectRatio() float32 { return float32(n.sys.av.g
|
|||
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) KbMouseSupport() bool { return n.meta.KbMouseSupport }
|
||||
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 }
|
||||
|
|
@ -174,6 +171,12 @@ func (n *Nanoarch) CoreLoad(meta Metadata) {
|
|||
// hacks
|
||||
Nan0.hackSkipHwContextDestroy = meta.HasHack("skip_hw_context_destroy")
|
||||
|
||||
// reset controllers
|
||||
n.retropad = InputState{}
|
||||
n.keyboardCb = nil
|
||||
n.keyboard = KeyboardState{}
|
||||
n.mouse = MouseState{}
|
||||
|
||||
n.options = maps.Clone(meta.Options)
|
||||
n.options4rom = meta.Options4rom
|
||||
|
||||
|
|
@ -312,7 +315,7 @@ func (n *Nanoarch) LoadGame(path string) error {
|
|||
|
||||
// set default controller types on all ports
|
||||
// needed for nestopia
|
||||
for i := range MaxPort {
|
||||
for i := range maxPort {
|
||||
C.bridge_retro_set_controller_port_device(retroSetControllerPortDevice, C.uint(i), C.RETRO_DEVICE_JOYPAD)
|
||||
}
|
||||
|
||||
|
|
@ -389,8 +392,34 @@ 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) IsGL() bool { return n.Video.gl.enabled }
|
||||
func (n *Nanoarch) IsStopped() bool { return n.Stopped.Load() }
|
||||
func (n *Nanoarch) InputRetropad(port int, data []byte) { n.retropad.Input(port, data) }
|
||||
func (n *Nanoarch) InputKeyboard(_ int, data []byte) {
|
||||
if n.keyboardCb == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// we should preserve the state of pressed buttons for the input poll function (each retro_run)
|
||||
// and explicitly call the retro_keyboard_callback function when a keyboard event happens
|
||||
pressed, key, mod := n.keyboard.SetKey(data)
|
||||
C.bridge_retro_keyboard_callback(unsafe.Pointer(n.keyboardCb), C.bool(pressed),
|
||||
C.unsigned(key), C.uint32_t(0), C.uint16_t(mod))
|
||||
}
|
||||
func (n *Nanoarch) InputMouse(_ int, data []byte) {
|
||||
if len(data) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
t := data[0]
|
||||
state := data[1:]
|
||||
switch t {
|
||||
case MouseMove:
|
||||
n.mouse.ShiftPos(state)
|
||||
case MouseButton:
|
||||
n.mouse.SetButtons(state[0])
|
||||
}
|
||||
}
|
||||
|
||||
func videoSetPixelFormat(format uint32) (C.bool, error) {
|
||||
switch format {
|
||||
|
|
@ -615,29 +644,55 @@ func coreInputPoll() {}
|
|||
|
||||
//export coreInputState
|
||||
func coreInputState(port C.unsigned, device C.unsigned, index C.unsigned, id C.unsigned) C.int16_t {
|
||||
if uint(port) >= uint(MaxPort) {
|
||||
return KeyReleased
|
||||
//Nan0.log.Debug().Msgf("%v %v %v %v", port, device, index, id)
|
||||
|
||||
// something like PCSX-ReArmed has 8 ports
|
||||
if port >= maxPort {
|
||||
return Released
|
||||
}
|
||||
|
||||
if device == C.RETRO_DEVICE_ANALOG {
|
||||
if index > C.RETRO_DEVICE_INDEX_ANALOG_RIGHT || id > C.RETRO_DEVICE_ID_ANALOG_Y {
|
||||
return 0
|
||||
switch device {
|
||||
case C.RETRO_DEVICE_JOYPAD:
|
||||
return Nan0.retropad.IsKeyPressed(uint(port), int(id))
|
||||
case C.RETRO_DEVICE_ANALOG:
|
||||
switch index {
|
||||
case C.RETRO_DEVICE_INDEX_ANALOG_LEFT:
|
||||
return Nan0.retropad.IsDpadTouched(uint(port), uint(index*2+id))
|
||||
case C.RETRO_DEVICE_INDEX_ANALOG_RIGHT:
|
||||
case C.RETRO_DEVICE_INDEX_ANALOG_BUTTON:
|
||||
}
|
||||
axis := index*2 + id
|
||||
value := Nan0.Handlers.OnDpad(uint(port), uint(axis))
|
||||
if value != 0 {
|
||||
return (C.int16_t)(value)
|
||||
case C.RETRO_DEVICE_KEYBOARD:
|
||||
return Nan0.keyboard.Pressed(uint(id))
|
||||
case C.RETRO_DEVICE_MOUSE:
|
||||
switch id {
|
||||
case C.RETRO_DEVICE_ID_MOUSE_X:
|
||||
x := Nan0.mouse.PopX()
|
||||
return x
|
||||
case C.RETRO_DEVICE_ID_MOUSE_Y:
|
||||
y := Nan0.mouse.PopY()
|
||||
return y
|
||||
case C.RETRO_DEVICE_ID_MOUSE_LEFT:
|
||||
if l, _, _ := Nan0.mouse.Buttons(); l {
|
||||
return Pressed
|
||||
}
|
||||
case C.RETRO_DEVICE_ID_MOUSE_RIGHT:
|
||||
if _, r, _ := Nan0.mouse.Buttons(); r {
|
||||
return Pressed
|
||||
}
|
||||
case C.RETRO_DEVICE_ID_MOUSE_WHEELUP:
|
||||
case C.RETRO_DEVICE_ID_MOUSE_WHEELDOWN:
|
||||
case C.RETRO_DEVICE_ID_MOUSE_MIDDLE:
|
||||
if _, _, m := Nan0.mouse.Buttons(); m {
|
||||
return Pressed
|
||||
}
|
||||
case C.RETRO_DEVICE_ID_MOUSE_HORIZ_WHEELUP:
|
||||
case C.RETRO_DEVICE_ID_MOUSE_HORIZ_WHEELDOWN:
|
||||
case C.RETRO_DEVICE_ID_MOUSE_BUTTON_4:
|
||||
case C.RETRO_DEVICE_ID_MOUSE_BUTTON_5:
|
||||
}
|
||||
}
|
||||
|
||||
key := int(id)
|
||||
if key > lastKey || index > 0 || device != C.RETRO_DEVICE_JOYPAD {
|
||||
return KeyReleased
|
||||
}
|
||||
if Nan0.Handlers.OnKeyPress(uint(port), key) == KeyPressed {
|
||||
return KeyPressed
|
||||
}
|
||||
return KeyReleased
|
||||
return Released
|
||||
}
|
||||
|
||||
//export coreAudioSample
|
||||
|
|
@ -798,9 +853,20 @@ func coreEnvironment(cmd C.unsigned, data unsafe.Pointer) C.bool {
|
|||
}
|
||||
cInfo.WriteString(fmt.Sprintf("%v: %v%s", cd[i].id, C.GoString(cd[i].desc), delim))
|
||||
}
|
||||
Nan0.log.Debug().Msgf("%v", cInfo.String())
|
||||
//Nan0.log.Debug().Msgf("%v", cInfo.String())
|
||||
}
|
||||
return true
|
||||
case C.RETRO_ENVIRONMENT_GET_INPUT_MAX_USERS:
|
||||
*(*C.unsigned)(data) = C.unsigned(4)
|
||||
Nan0.log.Debug().Msgf("Set max users: %v", 4)
|
||||
return true
|
||||
case C.RETRO_ENVIRONMENT_SET_KEYBOARD_CALLBACK:
|
||||
Nan0.log.Debug().Msgf("Keyboard event callback was set")
|
||||
Nan0.keyboardCb = (*C.struct_retro_keyboard_callback)(data)
|
||||
return true
|
||||
case C.RETRO_ENVIRONMENT_GET_INPUT_BITMASKS:
|
||||
Nan0.log.Debug().Msgf("Set input bitmasks: false")
|
||||
return false
|
||||
case C.RETRO_ENVIRONMENT_GET_CLEAR_ALL_THREAD_WAITS_CB:
|
||||
C.bridge_clear_all_thread_waits_cb(data)
|
||||
return true
|
||||
|
|
@ -906,9 +972,7 @@ func geometryChange(geom C.struct_retro_game_geometry) {
|
|||
|
||||
if Nan0.OnSystemAvInfo != nil {
|
||||
Nan0.log.Debug().Msgf(">>> geometry change %v -> %v", old, geom)
|
||||
if Nan0.Aspect {
|
||||
go Nan0.OnSystemAvInfo()
|
||||
}
|
||||
go Nan0.OnSystemAvInfo()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ void bridge_retro_set_input_poll(void *f, void *callback);
|
|||
void bridge_retro_set_input_state(void *f, void *callback);
|
||||
void bridge_retro_set_video_refresh(void *f, void *callback);
|
||||
void bridge_clear_all_thread_waits_cb(void *f);
|
||||
void bridge_retro_keyboard_callback(void *f, bool down, unsigned keycode, uint32_t character, uint16_t keyModifiers);
|
||||
|
||||
bool core_environment_cgo(unsigned cmd, void *data);
|
||||
int16_t core_input_state_cgo(unsigned port, unsigned device, unsigned index, unsigned id);
|
||||
|
|
|
|||
|
|
@ -126,12 +126,14 @@ func (c *coordinator) HandleGameStart(rq api.StartGameRequest[com.Uid], w *Worke
|
|||
}
|
||||
}
|
||||
|
||||
data, err := api.Wrap(api.Out{T: uint8(api.AppVideoChange), Payload: api.AppVideoInfo{
|
||||
W: m.VideoW,
|
||||
H: m.VideoH,
|
||||
A: app.AspectRatio(),
|
||||
S: int(app.Scale()),
|
||||
}})
|
||||
data, err := api.Wrap(api.Out{
|
||||
T: uint8(api.AppVideoChange),
|
||||
Payload: api.AppVideoInfo{
|
||||
W: m.VideoW,
|
||||
H: m.VideoH,
|
||||
A: app.AspectRatio(),
|
||||
S: int(app.Scale()),
|
||||
}})
|
||||
if err != nil {
|
||||
c.log.Error().Err(err).Msgf("wrap")
|
||||
}
|
||||
|
|
@ -159,6 +161,7 @@ func (c *coordinator) HandleGameStart(rq api.StartGameRequest[com.Uid], w *Worke
|
|||
w.router.SetRoom(nil)
|
||||
return api.EmptyPacket
|
||||
}
|
||||
|
||||
if app.Flipped() {
|
||||
m.SetVideoFlip(true)
|
||||
}
|
||||
|
|
@ -170,11 +173,23 @@ func (c *coordinator) HandleGameStart(rq api.StartGameRequest[com.Uid], w *Worke
|
|||
}
|
||||
|
||||
c.log.Debug().Msg("Start session input poll")
|
||||
room.WithWebRTC(user.Session).OnMessage = func(data []byte) { r.App().SendControl(user.Index, data) }
|
||||
|
||||
needsKbMouse := r.App().KbMouseSupport()
|
||||
|
||||
s := room.WithWebRTC(user.Session)
|
||||
s.OnMessage = func(data []byte) { r.App().Input(user.Index, byte(caged.RetroPad), data) }
|
||||
if needsKbMouse {
|
||||
_ = s.AddChannel("keyboard", func(data []byte) { r.App().Input(user.Index, byte(caged.Keyboard), data) })
|
||||
_ = s.AddChannel("mouse", func(data []byte) { r.App().Input(user.Index, byte(caged.Mouse), data) })
|
||||
}
|
||||
|
||||
c.RegisterRoom(r.Id())
|
||||
|
||||
response := 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,
|
||||
KbMouse: needsKbMouse,
|
||||
}
|
||||
if r.App().AspectEnabled() {
|
||||
ww, hh := r.App().ViewportSize()
|
||||
response.AV = &api.AppVideoInfo{W: ww, H: hh, A: r.App().AspectRatio(), S: int(r.App().Scale())}
|
||||
|
|
|
|||
119
web/css/main.css
119
web/css/main.css
|
|
@ -17,10 +17,6 @@ html {
|
|||
body {
|
||||
background-image: url('/img/background.jpg');
|
||||
background-repeat: repeat;
|
||||
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#gamebody {
|
||||
|
|
@ -96,7 +92,7 @@ body {
|
|||
color: #979797;
|
||||
font-size: 8px;
|
||||
top: 269px;
|
||||
left: 30px;
|
||||
left: 101px;
|
||||
position: absolute;
|
||||
|
||||
user-select: none;
|
||||
|
|
@ -404,7 +400,7 @@ body {
|
|||
object-fit: contain;
|
||||
width: inherit;
|
||||
height: inherit;
|
||||
background-color: #222222;
|
||||
background-color: #101010;
|
||||
}
|
||||
|
||||
#menu-screen {
|
||||
|
|
@ -487,67 +483,10 @@ body {
|
|||
|
||||
.menu-item__info {
|
||||
color: white;
|
||||
opacity: .55;
|
||||
font-size: 30%;
|
||||
position: absolute;
|
||||
left: 15px;
|
||||
}
|
||||
|
||||
.text-move {
|
||||
animation: horizontally 4s linear infinite alternate;
|
||||
}
|
||||
|
||||
@-moz-keyframes horizontally {
|
||||
0% {
|
||||
transform: translateX(0%);
|
||||
}
|
||||
25% {
|
||||
transform: translateX(-20%);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(0%);
|
||||
}
|
||||
75% {
|
||||
transform: translateX(20%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0%);
|
||||
}
|
||||
}
|
||||
|
||||
@-webkit-keyframes horizontally {
|
||||
0% {
|
||||
transform: translateX(0%);
|
||||
}
|
||||
25% {
|
||||
transform: translateX(-20%);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(0%);
|
||||
}
|
||||
75% {
|
||||
transform: translateX(20%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes horizontally {
|
||||
0% {
|
||||
transform: translateX(0%);
|
||||
}
|
||||
25% {
|
||||
transform: translateX(-20%);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(0%);
|
||||
}
|
||||
75% {
|
||||
transform: translateX(20%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0%);
|
||||
}
|
||||
text-align: center;
|
||||
padding-top: 3px;
|
||||
}
|
||||
|
||||
#noti-box {
|
||||
|
|
@ -635,50 +574,6 @@ body {
|
|||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
#stats-overlay {
|
||||
position: absolute;
|
||||
z-index: 200;
|
||||
backface-visibility: hidden;
|
||||
cursor: default;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-around;
|
||||
|
||||
top: 1.1em;
|
||||
right: 1.1em;
|
||||
color: #fff;
|
||||
background: #000;
|
||||
opacity: .465;
|
||||
|
||||
font-size: 1.45vh;
|
||||
font-family: monospace;
|
||||
min-width: 3em;
|
||||
|
||||
padding-right: .2em;
|
||||
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
#stats-overlay > div {
|
||||
display: flex;
|
||||
flex-flow: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#stats-overlay > div > div {
|
||||
display: inline-block;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#stats-overlay .graph {
|
||||
width: 100%;
|
||||
/* artifacts with pixelated option */
|
||||
/*image-rendering: pixelated;*/
|
||||
image-rendering: optimizeSpeed;
|
||||
}
|
||||
|
||||
.dpad-toggle-label {
|
||||
position: absolute;
|
||||
display: inline-block;
|
||||
|
|
@ -735,10 +630,6 @@ input:checked + .dpad-toggle-slider:before {
|
|||
text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000;
|
||||
}
|
||||
|
||||
.source #v {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.source a {
|
||||
color: #dddddd;
|
||||
}
|
||||
|
|
|
|||
114
web/css/ui.css
114
web/css/ui.css
|
|
@ -228,12 +228,26 @@
|
|||
color: #7e7e7e;
|
||||
}
|
||||
|
||||
.app-button.fs {
|
||||
position: relative;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
#mirror-stream {
|
||||
image-rendering: pixelated;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#screen {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
/*align-items: center;*/
|
||||
justify-content: center;
|
||||
|
||||
min-width: 0 !important;
|
||||
min-height: 0 !important;
|
||||
|
||||
position: absolute;
|
||||
/* popups under the screen fix */
|
||||
z-index: -1;
|
||||
|
|
@ -243,8 +257,104 @@
|
|||
top: 23px;
|
||||
left: 150px;
|
||||
overflow: hidden;
|
||||
background-color: #333;
|
||||
background-color: #000000;
|
||||
|
||||
border-radius: 5px 5px 5px 5px;
|
||||
box-shadow: 0 0 2px 2px rgba(25, 25, 25, 1);
|
||||
}
|
||||
|
||||
.screen__footer {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
border-top: 1px solid #1b1b1b;
|
||||
width: calc(100% - .6rem);
|
||||
justify-content: space-between;
|
||||
|
||||
background-color: #00000022;
|
||||
|
||||
height: 13px;
|
||||
font-size: .6rem;
|
||||
color: #ffffff;
|
||||
|
||||
opacity: .3;
|
||||
|
||||
padding: 0 .2em;
|
||||
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.hover:hover {
|
||||
opacity: .567;
|
||||
}
|
||||
|
||||
.with-footer {
|
||||
height: calc(100% - 14px);
|
||||
}
|
||||
|
||||
.kbm-button {
|
||||
top: 265px;
|
||||
left: 542px;
|
||||
|
||||
text-align: center;
|
||||
font-size: 70%;
|
||||
|
||||
opacity: .5;
|
||||
filter: contrast(.3);
|
||||
}
|
||||
|
||||
.kbm-button-fs {
|
||||
width: 1em;
|
||||
text-align: center;
|
||||
font-size: 110%;
|
||||
/*color: #ffffff;*/
|
||||
/*opacity: .5;*/
|
||||
filter: contrast(.3);
|
||||
}
|
||||
|
||||
.no-pointer {
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
#stats-overlay {
|
||||
cursor: default;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: end;
|
||||
|
||||
color: #fff;
|
||||
background: #000;
|
||||
/*opacity: .3;*/
|
||||
|
||||
font-size: 10px;
|
||||
font-family: monospace;
|
||||
min-width: 18em;
|
||||
|
||||
gap: 5px;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
#stats-overlay > div {
|
||||
display: flex;
|
||||
flex-flow: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#stats-overlay > div > div {
|
||||
display: inline-block;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
#stats-overlay .graph {
|
||||
width: 100%;
|
||||
/* artifacts with pixelated option */
|
||||
/*image-rendering: pixelated;*/
|
||||
image-rendering: optimizeSpeed;
|
||||
}
|
||||
|
||||
.stats-bitrate {
|
||||
min-width: 3.3rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,21 +32,27 @@
|
|||
</div>
|
||||
|
||||
<div id="screen">
|
||||
<div id="stats-overlay"></div>
|
||||
<video id="stream" class="game-screen" hidden muted preload="none"></video>
|
||||
<canvas id="mirror-stream" class="game-screen" hidden></canvas>
|
||||
<div id="menu-screen">
|
||||
<div id="menu-container"></div>
|
||||
<div id="menu-item-choice" class="hidden"></div>
|
||||
</div>
|
||||
<div class="screen__footer hover hidden">
|
||||
<div>cloudretro</div>
|
||||
<div class="app-button fs kbm-button-fs hidden" id="kbm2"
|
||||
title="Switch from keyboard/mouse to retropad">🎮+⌨️+🖱️
|
||||
</div>
|
||||
<div id="stats-overlay"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="servers"></div>
|
||||
<div id="settings"></div>
|
||||
|
||||
<div id="guide-txt">
|
||||
<b>Arrows</b> (move), <b>ZXCVAS;'./</b> (game ABXYL1-L3R1-R3), <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 the link to the
|
||||
clipboard)
|
||||
<b>Arrows</b> (move), <b>ZXCVAS;'./</b> (game ABXYL1-L3R1-R3),
|
||||
<b>Shift/Enter/K/L</b> (select/start/save/load), <b>F</b> (fullscreen), <b>share</b> (copy the link)
|
||||
</div>
|
||||
<div id="btn-join" class="btn big" data-key="join"></div>
|
||||
<div id="slider-playeridx" class="slidecontainer">
|
||||
|
|
@ -55,6 +61,9 @@
|
|||
</div>
|
||||
<div id="btn-quit" class="btn big" data-key="quit"></div>
|
||||
|
||||
<div class="app-button kbm-button hidden" id="kbm" title="Switch from keyboard/mouse to retropad">
|
||||
C🎮+K⌨️+M🖱️</div>
|
||||
|
||||
<div id="controls-right">
|
||||
<div id="btn-load" class="btn big hidden" data-key="load"></div>
|
||||
<div id="btn-save" class="btn big hidden" data-key="save"></div>
|
||||
|
|
@ -91,10 +100,9 @@
|
|||
<div id="btn-rec" class="btn" data-key="rec"></div>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="source">
|
||||
<span id="v">69ff8ae</span>
|
||||
<div class="source"><span>Cloudretro (ɔ) 2024</span>
|
||||
<a rel="noopener noreferrer" target="_blank" href="https://github.com/giongto35/cloud-game">
|
||||
Source code on GitHub
|
||||
<span id="v">69ff8ae</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
295
web/js/api.js
295
web/js/api.js
|
|
@ -19,6 +19,277 @@ const endpoints = {
|
|||
APP_VIDEO_CHANGE: 150,
|
||||
}
|
||||
|
||||
let transport = {
|
||||
send: (packet) => {
|
||||
log.warn('Default transport is used! Change it with the api.transport variable.', packet)
|
||||
},
|
||||
keyboard: (packet) => {
|
||||
log.warn('Default transport is used! Change it with the api.transport variable.', packet)
|
||||
},
|
||||
mouse: (packet) => {
|
||||
log.warn('Default transport is used! Change it with the api.transport variable.', packet)
|
||||
}
|
||||
}
|
||||
|
||||
const packet = (type, payload, id) => {
|
||||
const packet = {t: type}
|
||||
if (id !== undefined) packet.id = id
|
||||
if (payload !== undefined) packet.p = payload
|
||||
transport.send(packet)
|
||||
}
|
||||
|
||||
const decodeBytes = (b) => String.fromCharCode.apply(null, new Uint8Array(b))
|
||||
|
||||
const keyboardPress = (() => {
|
||||
// 0 1 2 3 4 5 6
|
||||
// [CODE ] P MOD
|
||||
const buffer = new ArrayBuffer(7)
|
||||
const dv = new DataView(buffer)
|
||||
|
||||
return (pressed = false, e) => {
|
||||
if (e.repeat) return // skip pressed key events
|
||||
|
||||
const key = libretro.mod
|
||||
let code = libretro.map('', e.code)
|
||||
let shift = e.shiftKey
|
||||
|
||||
// a special Esc for &$&!& Firefox
|
||||
if (shift && code === 96) {
|
||||
code = 27
|
||||
shift = false
|
||||
}
|
||||
|
||||
const mod = 0
|
||||
| (e.altKey && key.ALT)
|
||||
| (e.ctrlKey && key.CTRL)
|
||||
| (e.metaKey && key.META)
|
||||
| (shift && key.SHIFT)
|
||||
| (e.getModifierState('NumLock') && key.NUMLOCK)
|
||||
| (e.getModifierState('CapsLock') && key.CAPSLOCK)
|
||||
| (e.getModifierState('ScrollLock') && key.SCROLLOCK)
|
||||
dv.setUint32(0, code)
|
||||
dv.setUint8(4, +pressed)
|
||||
dv.setUint16(5, mod)
|
||||
transport.keyboard(buffer)
|
||||
}
|
||||
})()
|
||||
|
||||
const mouse = {
|
||||
MOVEMENT: 0,
|
||||
BUTTONS: 1
|
||||
}
|
||||
|
||||
const mouseMove = (() => {
|
||||
// 0 1 2 3 4
|
||||
// T DX DY
|
||||
const buffer = new ArrayBuffer(5)
|
||||
const dv = new DataView(buffer)
|
||||
|
||||
return (dx = 0, dy = 0) => {
|
||||
dv.setUint8(0, mouse.MOVEMENT)
|
||||
dv.setInt16(1, dx)
|
||||
dv.setInt16(3, dy)
|
||||
transport.mouse(buffer)
|
||||
}
|
||||
})()
|
||||
|
||||
const mousePress = (() => {
|
||||
// 0 1
|
||||
// T B
|
||||
const buffer = new ArrayBuffer(2)
|
||||
const dv = new DataView(buffer)
|
||||
|
||||
// 0: Main button pressed, usually the left button or the un-initialized state
|
||||
// 1: Auxiliary button pressed, usually the wheel button or the middle button (if present)
|
||||
// 2: Secondary button pressed, usually the right button
|
||||
// 3: Fourth button, typically the Browser Back button
|
||||
// 4: Fifth button, typically the Browser Forward button
|
||||
|
||||
const b2r = [1, 4, 2, 0, 0] // browser mouse button to retro button
|
||||
// assumed that only one button pressed / released
|
||||
|
||||
return (button = 0, pressed = false) => {
|
||||
dv.setUint8(0, mouse.BUTTONS)
|
||||
dv.setUint8(1, pressed ? b2r[button] : 0)
|
||||
transport.mouse(buffer)
|
||||
}
|
||||
})()
|
||||
|
||||
|
||||
const libretro = function () {// RETRO_KEYBOARD
|
||||
const retro = {
|
||||
'': 0,
|
||||
'Unidentified': 0,
|
||||
'Unknown': 0, // ???
|
||||
'First': 0, // ???
|
||||
'Backspace': 8,
|
||||
'Tab': 9,
|
||||
'Clear': 12,
|
||||
'Enter': 13, 'Return': 13,
|
||||
'Pause': 19,
|
||||
'Escape': 27,
|
||||
'Space': 32,
|
||||
'Exclaim': 33,
|
||||
'Quotedbl': 34,
|
||||
'Hash': 35,
|
||||
'Dollar': 36,
|
||||
'Ampersand': 38,
|
||||
'Quote': 39,
|
||||
'Leftparen': 40, '(': 40,
|
||||
'Rightparen': 41, ')': 41,
|
||||
'Asterisk': 42,
|
||||
'Plus': 43,
|
||||
'Comma': 44,
|
||||
'Minus': 45,
|
||||
'Period': 46,
|
||||
'Slash': 47,
|
||||
'Digit0': 48,
|
||||
'Digit1': 49,
|
||||
'Digit2': 50,
|
||||
'Digit3': 51,
|
||||
'Digit4': 52,
|
||||
'Digit5': 53,
|
||||
'Digit6': 54,
|
||||
'Digit7': 55,
|
||||
'Digit8': 56,
|
||||
'Digit9': 57,
|
||||
'Colon': 58, ':': 58,
|
||||
'Semicolon': 59, ';': 59,
|
||||
'Less': 60, '<': 60,
|
||||
'Equal': 61, '=': 61,
|
||||
'Greater': 62, '>': 62,
|
||||
'Question': 63, '?': 63,
|
||||
// RETROK_AT = 64,
|
||||
'BracketLeft': 91, '[': 91,
|
||||
'Backslash': 92, '\\': 92,
|
||||
'BracketRight': 93, ']': 93,
|
||||
// RETROK_CARET = 94,
|
||||
// RETROK_UNDERSCORE = 95,
|
||||
'Backquote': 96, '`': 96,
|
||||
'KeyA': 97,
|
||||
'KeyB': 98,
|
||||
'KeyC': 99,
|
||||
'KeyD': 100,
|
||||
'KeyE': 101,
|
||||
'KeyF': 102,
|
||||
'KeyG': 103,
|
||||
'KeyH': 104,
|
||||
'KeyI': 105,
|
||||
'KeyJ': 106,
|
||||
'KeyK': 107,
|
||||
'KeyL': 108,
|
||||
'KeyM': 109,
|
||||
'KeyN': 110,
|
||||
'KeyO': 111,
|
||||
'KeyP': 112,
|
||||
'KeyQ': 113,
|
||||
'KeyR': 114,
|
||||
'KeyS': 115,
|
||||
'KeyT': 116,
|
||||
'KeyU': 117,
|
||||
'KeyV': 118,
|
||||
'KeyW': 119,
|
||||
'KeyX': 120,
|
||||
'KeyY': 121,
|
||||
'KeyZ': 122,
|
||||
'{': 123,
|
||||
'|': 124,
|
||||
'}': 125,
|
||||
'Tilde': 126, '~': 126,
|
||||
'Delete': 127,
|
||||
|
||||
'Numpad0': 256,
|
||||
'Numpad1': 257,
|
||||
'Numpad2': 258,
|
||||
'Numpad3': 259,
|
||||
'Numpad4': 260,
|
||||
'Numpad5': 261,
|
||||
'Numpad6': 262,
|
||||
'Numpad7': 263,
|
||||
'Numpad8': 264,
|
||||
'Numpad9': 265,
|
||||
'NumpadDecimal': 266,
|
||||
'NumpadDivide': 267,
|
||||
'NumpadMultiply': 268,
|
||||
'NumpadSubtract': 269,
|
||||
'NumpadAdd': 270,
|
||||
'NumpadEnter': 271,
|
||||
'NumpadEqual': 272,
|
||||
|
||||
'ArrowUp': 273,
|
||||
'ArrowDown': 274,
|
||||
'ArrowRight': 275,
|
||||
'ArrowLeft': 276,
|
||||
'Insert': 277,
|
||||
'Home': 278,
|
||||
'End': 279,
|
||||
'PageUp': 280,
|
||||
'PageDown': 281,
|
||||
|
||||
'F1': 282,
|
||||
'F2': 283,
|
||||
'F3': 284,
|
||||
'F4': 285,
|
||||
'F5': 286,
|
||||
'F6': 287,
|
||||
'F7': 288,
|
||||
'F8': 289,
|
||||
'F9': 290,
|
||||
'F10': 291,
|
||||
'F11': 292,
|
||||
'F12': 293,
|
||||
'F13': 294,
|
||||
'F14': 295,
|
||||
'F15': 296,
|
||||
|
||||
'NumLock': 300,
|
||||
'CapsLock': 301,
|
||||
'ScrollLock': 302,
|
||||
'ShiftRight': 303,
|
||||
'ShiftLeft': 304,
|
||||
'ControlRight': 305,
|
||||
'ControlLeft': 306,
|
||||
'AltRight': 307,
|
||||
'AltLeft': 308,
|
||||
'MetaRight': 309,
|
||||
'MetaLeft': 310,
|
||||
// RETROK_LSUPER = 311,
|
||||
// RETROK_RSUPER = 312,
|
||||
// RETROK_MODE = 313,
|
||||
// RETROK_COMPOSE = 314,
|
||||
|
||||
// RETROK_HELP = 315,
|
||||
// RETROK_PRINT = 316,
|
||||
// RETROK_SYSREQ = 317,
|
||||
// RETROK_BREAK = 318,
|
||||
// RETROK_MENU = 319,
|
||||
'Power': 320,
|
||||
// RETROK_EURO = 321,
|
||||
// RETROK_UNDO = 322,
|
||||
// RETROK_OEM_102 = 323,
|
||||
}
|
||||
|
||||
const retroMod = {
|
||||
NONE: 0x0000,
|
||||
SHIFT: 0x01,
|
||||
CTRL: 0x02,
|
||||
ALT: 0x04,
|
||||
META: 0x08,
|
||||
NUMLOCK: 0x10,
|
||||
CAPSLOCK: 0x20,
|
||||
SCROLLOCK: 0x40,
|
||||
}
|
||||
|
||||
const _map = (key = '', code = '') => {
|
||||
return retro[code] || retro[key] || 0
|
||||
}
|
||||
|
||||
return {
|
||||
map: _map,
|
||||
mod: retroMod,
|
||||
}
|
||||
}()
|
||||
|
||||
/**
|
||||
* Server API.
|
||||
*
|
||||
|
|
@ -38,6 +309,15 @@ export const api = {
|
|||
getWorkerList: () => packet(endpoints.GET_WORKER_LIST),
|
||||
},
|
||||
game: {
|
||||
input: {
|
||||
keyboard: {
|
||||
press: keyboardPress,
|
||||
},
|
||||
mouse: {
|
||||
move: mouseMove,
|
||||
press: mousePress,
|
||||
}
|
||||
},
|
||||
load: () => packet(endpoints.GAME_LOAD),
|
||||
save: () => packet(endpoints.GAME_SAVE),
|
||||
setPlayerIndex: (i) => packet(endpoints.GAME_SET_PLAYER_INDEX, i),
|
||||
|
|
@ -53,18 +333,3 @@ export const api = {
|
|||
quit: (roomId) => packet(endpoints.GAME_QUIT, {room_id: roomId}),
|
||||
}
|
||||
}
|
||||
|
||||
let transport = {
|
||||
send: (packet) => {
|
||||
log.warn('Default transport is used! Change it with the api.transport variable.', packet)
|
||||
}
|
||||
}
|
||||
|
||||
const packet = (type, payload, id) => {
|
||||
const packet = {t: type};
|
||||
if (id !== undefined) packet.id = id;
|
||||
if (payload !== undefined) packet.p = payload;
|
||||
transport.send(packet);
|
||||
}
|
||||
|
||||
const decodeBytes = (b) => String.fromCharCode.apply(null, new Uint8Array(b))
|
||||
|
|
|
|||
182
web/js/app.js
182
web/js/app.js
|
|
@ -1,19 +1,13 @@
|
|||
import {log} from 'log';
|
||||
import {opts, settings} from 'settings';
|
||||
|
||||
settings.init();
|
||||
log.level = settings.loadOr(opts.LOG_LEVEL, log.DEFAULT);
|
||||
|
||||
import {api} from 'api';
|
||||
import {
|
||||
pub,
|
||||
sub,
|
||||
APP_VIDEO_CHANGED,
|
||||
AXIS_CHANGED,
|
||||
CONTROLLER_UPDATED,
|
||||
DPAD_TOGGLE,
|
||||
FULLSCREEN_CHANGE,
|
||||
GAME_ERROR_NO_FREE_SLOTS,
|
||||
GAME_LOADED,
|
||||
GAME_PLAYER_IDX,
|
||||
GAME_PLAYER_IDX_SET,
|
||||
GAME_ROOM_AVAILABLE,
|
||||
|
|
@ -21,12 +15,19 @@ import {
|
|||
GAMEPAD_CONNECTED,
|
||||
GAMEPAD_DISCONNECTED,
|
||||
HELP_OVERLAY_TOGGLED,
|
||||
KB_MOUSE_FLAG,
|
||||
KEY_PRESSED,
|
||||
KEY_RELEASED,
|
||||
KEYBOARD_KEY_DOWN,
|
||||
KEYBOARD_KEY_UP,
|
||||
LATENCY_CHECK_REQUESTED,
|
||||
MESSAGE,
|
||||
MOUSE_MOVED,
|
||||
MOUSE_PRESSED,
|
||||
POINTER_LOCK_CHANGE,
|
||||
RECORDING_STATUS_CHANGED,
|
||||
RECORDING_TOGGLED,
|
||||
REFRESH_INPUT,
|
||||
SETTINGS_CHANGED,
|
||||
WEBRTC_CONNECTION_CLOSED,
|
||||
WEBRTC_CONNECTION_READY,
|
||||
|
|
@ -37,9 +38,11 @@ import {
|
|||
WEBRTC_SDP_ANSWER,
|
||||
WEBRTC_SDP_OFFER,
|
||||
WORKER_LIST_FETCHED,
|
||||
pub,
|
||||
sub,
|
||||
} from 'event';
|
||||
import {gui} from 'gui';
|
||||
import {keyboard, KEY, joystick, retropad, touch} from 'input';
|
||||
import {input, KEY} from 'input';
|
||||
import {socket, webrtc} from 'network';
|
||||
import {debounce} from 'utils';
|
||||
|
||||
|
|
@ -53,7 +56,10 @@ import {stats} from './stats.js?v=3';
|
|||
import {stream} from './stream.js?v=3';
|
||||
import {workerManager} from "./workerManager.js?v=3";
|
||||
|
||||
// application state
|
||||
settings.init();
|
||||
log.level = settings.loadOr(opts.LOG_LEVEL, log.DEFAULT);
|
||||
|
||||
// application display state
|
||||
let state;
|
||||
let lastState;
|
||||
|
||||
|
|
@ -102,18 +108,7 @@ const setState = (newState = app.state.eden) => {
|
|||
}
|
||||
};
|
||||
|
||||
const onGameRoomAvailable = () => {
|
||||
// room is ready
|
||||
};
|
||||
|
||||
const onConnectionReady = () => {
|
||||
// start a game right away or show the menu
|
||||
if (room.getId()) {
|
||||
startGame();
|
||||
} else {
|
||||
state.menuReady();
|
||||
}
|
||||
};
|
||||
const onConnectionReady = () => room.id ? startGame() : state.menuReady()
|
||||
|
||||
const onLatencyCheck = async (data) => {
|
||||
message.show('Connecting to fastest server...');
|
||||
|
|
@ -169,23 +164,21 @@ const startGame = () => {
|
|||
|
||||
setState(app.state.game);
|
||||
|
||||
stream.play()
|
||||
screen.toggle(stream)
|
||||
|
||||
api.game.start(
|
||||
gameList.selected,
|
||||
room.getId(),
|
||||
room.id,
|
||||
recording.isActive(),
|
||||
recording.getUser(),
|
||||
+playerIndex.value - 1,
|
||||
);
|
||||
)
|
||||
|
||||
// clear menu screen
|
||||
retropad.poll.disable();
|
||||
screen.toggle(stream);
|
||||
gameList.disable()
|
||||
input.retropad.toggle(false)
|
||||
gui.show(keyButtons[KEY.SAVE]);
|
||||
gui.show(keyButtons[KEY.LOAD]);
|
||||
// end clear
|
||||
retropad.poll.enable();
|
||||
input.retropad.toggle(true)
|
||||
};
|
||||
|
||||
const saveGame = debounce(() => api.game.save(), 1000);
|
||||
|
|
@ -204,16 +197,14 @@ const onMessage = (m) => {
|
|||
pub(WEBRTC_ICE_CANDIDATE_RECEIVED, {candidate: payload});
|
||||
break;
|
||||
case api.endpoint.GAME_START:
|
||||
if (payload.av) {
|
||||
pub(APP_VIDEO_CHANGED, payload.av)
|
||||
}
|
||||
payload.av && pub(APP_VIDEO_CHANGED, payload.av)
|
||||
payload.kb_mouse && pub(KB_MOUSE_FLAG)
|
||||
pub(GAME_ROOM_AVAILABLE, {roomId: payload.roomId});
|
||||
break;
|
||||
case api.endpoint.GAME_SAVE:
|
||||
pub(GAME_SAVED);
|
||||
break;
|
||||
case api.endpoint.GAME_LOAD:
|
||||
pub(GAME_LOADED);
|
||||
break;
|
||||
case api.endpoint.GAME_SET_PLAYER_INDEX:
|
||||
pub(GAME_PLAYER_IDX_SET, payload);
|
||||
|
|
@ -252,7 +243,7 @@ const onKeyPress = (data) => {
|
|||
if (KEY.HELP === data.key) helpScreen.show(true, event);
|
||||
}
|
||||
|
||||
state.keyPress(data.key);
|
||||
state.keyPress(data.key, data.code)
|
||||
};
|
||||
|
||||
// pre-state key release handler
|
||||
|
|
@ -279,7 +270,7 @@ const onKeyRelease = data => {
|
|||
// change app state if settings
|
||||
if (KEY.SETTINGS === data.key) setState(app.state.settings);
|
||||
|
||||
state.keyRelease(data.key);
|
||||
state.keyRelease(data.key, data.code);
|
||||
};
|
||||
|
||||
const updatePlayerIndex = (idx, not_game = false) => {
|
||||
|
|
@ -301,8 +292,10 @@ const onAxisChanged = (data) => {
|
|||
state.axisChanged(data.id, data.value);
|
||||
};
|
||||
|
||||
const handleToggle = () => {
|
||||
const handleToggle = (force = false) => {
|
||||
const toggle = document.getElementById('dpad-toggle');
|
||||
|
||||
force && toggle.setAttribute('checked', '')
|
||||
toggle.checked = !toggle.checked;
|
||||
pub(DPAD_TOGGLE, {checked: toggle.checked});
|
||||
};
|
||||
|
|
@ -402,10 +395,13 @@ const app = {
|
|||
game: {
|
||||
..._default,
|
||||
name: 'game',
|
||||
axisChanged: (id, value) => retropad.setAxisChanged(id, value),
|
||||
keyPress: key => retropad.setKeyState(key, true),
|
||||
axisChanged: (id, value) => input.retropad.setAxisChanged(id, value),
|
||||
keyboardInput: (pressed, e) => api.game.input.keyboard.press(pressed, e),
|
||||
mouseMove: (e) => api.game.input.mouse.move(e.dx, e.dy),
|
||||
mousePress: (e) => api.game.input.mouse.press(e.b, e.p),
|
||||
keyPress: (key) => input.retropad.setKeyState(key, true),
|
||||
keyRelease: function (key) {
|
||||
retropad.setKeyState(key, false);
|
||||
input.retropad.setKeyState(key, false);
|
||||
|
||||
switch (key) {
|
||||
case KEY.JOIN: // or SHARE
|
||||
|
|
@ -436,8 +432,8 @@ const app = {
|
|||
updatePlayerIndex(3);
|
||||
break;
|
||||
case KEY.QUIT:
|
||||
retropad.poll.disable();
|
||||
api.game.quit(room.getId());
|
||||
input.retropad.toggle(false)
|
||||
api.game.quit(room.id)
|
||||
room.reset();
|
||||
window.location = window.location.pathname;
|
||||
break;
|
||||
|
|
@ -453,10 +449,37 @@ const app = {
|
|||
}
|
||||
};
|
||||
|
||||
// switch keyboard+mouse / retropad
|
||||
const kbmEl = document.getElementById('kbm')
|
||||
const kbmEl2 = document.getElementById('kbm2')
|
||||
let kbmSkip = false
|
||||
const kbmCb = () => {
|
||||
input.kbm = kbmSkip
|
||||
kbmSkip = !kbmSkip
|
||||
pub(REFRESH_INPUT)
|
||||
}
|
||||
gui.multiToggle([kbmEl, kbmEl2], {
|
||||
list: [
|
||||
{caption: '⌨️+🖱️', cb: kbmCb},
|
||||
{caption: ' 🎮 ', cb: kbmCb}
|
||||
]
|
||||
})
|
||||
sub(KB_MOUSE_FLAG, () => {
|
||||
gui.show(kbmEl, kbmEl2)
|
||||
handleToggle(true)
|
||||
message.show('Keyboard and mouse work in fullscreen')
|
||||
})
|
||||
|
||||
// Browser lock API
|
||||
document.onpointerlockchange = () => pub(POINTER_LOCK_CHANGE, document.pointerLockElement)
|
||||
document.onfullscreenchange = () => pub(FULLSCREEN_CHANGE, document.fullscreenElement)
|
||||
|
||||
// subscriptions
|
||||
sub(MESSAGE, onMessage);
|
||||
|
||||
sub(GAME_ROOM_AVAILABLE, onGameRoomAvailable, 2);
|
||||
sub(GAME_ROOM_AVAILABLE, async () => {
|
||||
stream.play()
|
||||
}, 2)
|
||||
sub(GAME_SAVED, () => message.show('Saved'));
|
||||
sub(GAME_PLAYER_IDX, data => {
|
||||
updatePlayerIndex(+data.index, state !== app.state.game);
|
||||
|
|
@ -479,14 +502,25 @@ sub(WEBRTC_ICE_CANDIDATE_RECEIVED, (data) => webrtc.addCandidate(data.candidate)
|
|||
sub(WEBRTC_ICE_CANDIDATES_FLUSH, () => webrtc.flushCandidates());
|
||||
sub(WEBRTC_CONNECTION_READY, onConnectionReady);
|
||||
sub(WEBRTC_CONNECTION_CLOSED, () => {
|
||||
retropad.poll.disable();
|
||||
input.retropad.toggle(false)
|
||||
webrtc.stop();
|
||||
});
|
||||
sub(LATENCY_CHECK_REQUESTED, onLatencyCheck);
|
||||
sub(GAMEPAD_CONNECTED, () => message.show('Gamepad connected'));
|
||||
sub(GAMEPAD_DISCONNECTED, () => message.show('Gamepad disconnected'));
|
||||
|
||||
// keyboard handler in the Screen Lock mode
|
||||
sub(KEYBOARD_KEY_DOWN, (v) => state.keyboardInput?.(true, v))
|
||||
sub(KEYBOARD_KEY_UP, (v) => state.keyboardInput?.(false, v))
|
||||
|
||||
// mouse handler in the Screen Lock mode
|
||||
sub(MOUSE_MOVED, (e) => state.mouseMove?.(e))
|
||||
sub(MOUSE_PRESSED, (e) => state.mousePress?.(e))
|
||||
|
||||
// general keyboard handler
|
||||
sub(KEY_PRESSED, onKeyPress);
|
||||
sub(KEY_RELEASED, onKeyRelease);
|
||||
|
||||
sub(SETTINGS_CHANGED, () => message.show('Settings have been updated'));
|
||||
sub(AXIS_CHANGED, onAxisChanged);
|
||||
sub(CONTROLLER_UPDATED, data => webrtc.input(data));
|
||||
|
|
@ -496,18 +530,13 @@ sub(RECORDING_STATUS_CHANGED, handleRecordingStatus);
|
|||
sub(SETTINGS_CHANGED, () => {
|
||||
const s = settings.get();
|
||||
log.level = s[opts.LOG_LEVEL];
|
||||
if (state.showPing !== s[opts.SHOW_PING]) {
|
||||
state.showPing = s[opts.SHOW_PING];
|
||||
stats.toggle();
|
||||
}
|
||||
});
|
||||
|
||||
// initial app state
|
||||
setState(app.state.eden);
|
||||
|
||||
keyboard.init();
|
||||
joystick.init();
|
||||
touch.init();
|
||||
input.init()
|
||||
|
||||
stream.init();
|
||||
screen.init();
|
||||
|
||||
|
|
@ -516,28 +545,72 @@ let [roomId, zone] = room.loadMaybe();
|
|||
const wid = new URLSearchParams(document.location.search).get('wid');
|
||||
// if from URL -> start game immediately!
|
||||
socket.init(roomId, wid, zone);
|
||||
api.transport = socket;
|
||||
api.transport = {
|
||||
send: socket.send,
|
||||
keyboard: webrtc.keyboard,
|
||||
mouse: webrtc.mouse,
|
||||
}
|
||||
|
||||
// stats
|
||||
let WEBRTC_STATS_RTT;
|
||||
let VIDEO_BITRATE;
|
||||
let GET_V_CODEC, SET_CODEC;
|
||||
|
||||
const bitrate = (() => {
|
||||
let bytesPrev, timestampPrev
|
||||
const w = [0, 0, 0, 0, 0, 0]
|
||||
const n = w.length
|
||||
let i = 0
|
||||
return (now, bytes) => {
|
||||
w[i++ % n] = timestampPrev ? Math.floor(8 * (bytes - bytesPrev) / (now - timestampPrev)) : 0
|
||||
bytesPrev = bytes
|
||||
timestampPrev = now
|
||||
return Math.floor(w.reduce((a, b) => a + b) / n)
|
||||
}
|
||||
})()
|
||||
|
||||
stats.modules = [
|
||||
{
|
||||
mui: stats.mui(),
|
||||
mui: stats.mui('', '<1'),
|
||||
init() {
|
||||
WEBRTC_STATS_RTT = (v) => (this.val = v)
|
||||
},
|
||||
},
|
||||
{
|
||||
mui: stats.mui('', '', false, () => ''),
|
||||
init() {
|
||||
GET_V_CODEC = (v) => (this.val = v + ' @ ')
|
||||
}
|
||||
},
|
||||
{
|
||||
mui: stats.mui('', '', false, () => ''),
|
||||
init() {
|
||||
sub(APP_VIDEO_CHANGED, (payload) => (this.val = `${payload.w}x${payload.h}`))
|
||||
},
|
||||
},
|
||||
{
|
||||
mui: stats.mui('', '', false, () => ' kb/s', 'stats-bitrate'),
|
||||
init() {
|
||||
VIDEO_BITRATE = (v) => (this.val = v)
|
||||
}
|
||||
},
|
||||
{
|
||||
async stats() {
|
||||
const stats = await webrtc.stats();
|
||||
if (!stats) return;
|
||||
|
||||
stats.forEach(report => {
|
||||
const {nominated, currentRoundTripTime} = report;
|
||||
if (!SET_CODEC && report.mimeType?.startsWith('video/')) {
|
||||
GET_V_CODEC(report.mimeType.replace('video/', '').toLowerCase())
|
||||
SET_CODEC = 1
|
||||
}
|
||||
const {nominated, currentRoundTripTime, type, kind} = report;
|
||||
if (nominated && currentRoundTripTime !== undefined) {
|
||||
WEBRTC_STATS_RTT(currentRoundTripTime * 1000);
|
||||
}
|
||||
if (type === 'inbound-rtp' && kind === 'video') {
|
||||
VIDEO_BITRATE(bitrate(report.timestamp, report.bytesReceived))
|
||||
}
|
||||
});
|
||||
},
|
||||
enable() {
|
||||
|
|
@ -548,5 +621,4 @@ stats.modules = [
|
|||
},
|
||||
}]
|
||||
|
||||
state.showPing = settings.loadOr(opts.SHOW_PING, true);
|
||||
state.showPing && stats.toggle();
|
||||
stats.toggle()
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
import {
|
||||
pub,
|
||||
TRANSFORM_CHANGE
|
||||
} from 'event';
|
||||
|
||||
// UI
|
||||
const page = document.getElementsByTagName('html')[0];
|
||||
const gameBoy = document.getElementById('gamebody');
|
||||
|
|
@ -47,6 +52,8 @@ const rescaleGameBoy = (targetWidth, targetHeight) => {
|
|||
gameBoy.style['transform'] = transformations.join(' ');
|
||||
}
|
||||
|
||||
new MutationObserver(() => pub(TRANSFORM_CHANGE)).observe(gameBoy, {attributeFilter: ['style']})
|
||||
|
||||
const os = () => {
|
||||
const ua = window.navigator.userAgent;
|
||||
// noinspection JSUnresolvedReference,JSDeprecatedSymbols
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@ export const WORKER_LIST_FETCHED = 'workerListFetched';
|
|||
|
||||
export const GAME_ROOM_AVAILABLE = 'gameRoomAvailable';
|
||||
export const GAME_SAVED = 'gameSaved';
|
||||
export const GAME_LOADED = 'gameLoaded';
|
||||
export const GAME_PLAYER_IDX = 'gamePlayerIndex';
|
||||
export const GAME_PLAYER_IDX_SET = 'gamePlayerIndexSet'
|
||||
export const GAME_ERROR_NO_FREE_SLOTS = 'gameNoFreeSlots'
|
||||
|
|
@ -83,9 +82,19 @@ export const KEY_PRESSED = 'keyPressed';
|
|||
export const KEY_RELEASED = 'keyReleased';
|
||||
export const KEYBOARD_TOGGLE_FILTER_MODE = 'keyboardToggleFilterMode';
|
||||
export const KEYBOARD_KEY_PRESSED = 'keyboardKeyPressed';
|
||||
export const KEYBOARD_KEY_DOWN = 'keyboardKeyDown';
|
||||
export const KEYBOARD_KEY_UP = 'keyboardKeyUp';
|
||||
|
||||
export const AXIS_CHANGED = 'axisChanged';
|
||||
export const CONTROLLER_UPDATED = 'controllerUpdated';
|
||||
|
||||
export const MOUSE_MOVED = 'mouseMoved'
|
||||
export const MOUSE_PRESSED = 'mousePressed'
|
||||
|
||||
export const FULLSCREEN_CHANGE = 'fsc'
|
||||
export const POINTER_LOCK_CHANGE = 'plc'
|
||||
export const TRANSFORM_CHANGE = 'tc'
|
||||
|
||||
export const DPAD_TOGGLE = 'dpadToggle';
|
||||
export const HELP_OVERLAY_TOGGLED = 'helpOverlayToggled';
|
||||
|
||||
|
|
@ -95,3 +104,6 @@ export const RECORDING_TOGGLED = 'recordingToggle'
|
|||
export const RECORDING_STATUS_CHANGED = 'recordingStatusChanged'
|
||||
|
||||
export const APP_VIDEO_CHANGED = 'appVideoChanged'
|
||||
export const KB_MOUSE_FLAG = 'kbMouseFlag'
|
||||
|
||||
export const REFRESH_INPUT = 'refreshInput'
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
import {
|
||||
sub,
|
||||
MENU_PRESSED,
|
||||
MENU_RELEASED
|
||||
} from 'event';
|
||||
import {MENU_PRESSED, MENU_RELEASED, sub} from 'event';
|
||||
import {gui} from 'gui';
|
||||
|
||||
const TOP_POSITION = 102
|
||||
|
|
@ -21,13 +17,6 @@ const games = (() => {
|
|||
return list[index].title // selected by the game title, oof
|
||||
},
|
||||
set index(i) {
|
||||
//-2 |
|
||||
//-1 | |
|
||||
// 0 < | <
|
||||
// 1 | |
|
||||
// 2 < < |
|
||||
//+1 | |
|
||||
//+2 |
|
||||
index = i < -1 ? i = 0 :
|
||||
i > list.length ? i = list.length - 1 :
|
||||
(i % list.length + list.length) % list.length
|
||||
|
|
@ -90,10 +79,41 @@ const ui = (() => {
|
|||
|
||||
let onTransitionEnd = () => ({})
|
||||
|
||||
//rootEl.addEventListener('transitionend', () => onTransitionEnd())
|
||||
|
||||
let items = []
|
||||
|
||||
const marque = (() => {
|
||||
const speed = 1
|
||||
const sep = ' '.repeat(10)
|
||||
|
||||
let el = null
|
||||
let raf = 0
|
||||
let txt = null
|
||||
let w = 0
|
||||
|
||||
const move = () => {
|
||||
const shift = parseFloat(getComputedStyle(el).left) - speed
|
||||
el.style.left = w + shift < 1 ? `0px` : `${shift}px`
|
||||
raf = requestAnimationFrame(move)
|
||||
}
|
||||
|
||||
return {
|
||||
reset() {
|
||||
cancelAnimationFrame(raf)
|
||||
el && (el.style.left = `0px`)
|
||||
},
|
||||
enable(cap) {
|
||||
txt && (el.textContent = txt) // restore the text
|
||||
el = cap
|
||||
txt = el.textContent
|
||||
el.textContent += sep
|
||||
w = el.scrollWidth // keep the text width
|
||||
el.textContent += txt
|
||||
cancelAnimationFrame(raf)
|
||||
raf = requestAnimationFrame(move)
|
||||
}
|
||||
}
|
||||
})()
|
||||
|
||||
const item = (parent) => {
|
||||
const title = parent.firstChild.firstChild
|
||||
const desc = parent.children[1]
|
||||
|
|
@ -106,16 +126,20 @@ const ui = (() => {
|
|||
},
|
||||
}
|
||||
|
||||
const isOverflown = () => title.scrollWidth > title.clientWidth
|
||||
|
||||
const _title = {
|
||||
animate: () => title.classList.add('text-move'),
|
||||
pick: () => title.classList.add('pick'),
|
||||
reset: () => title.classList.remove('pick', 'text-move'),
|
||||
pick: () => {
|
||||
title.classList.add('pick')
|
||||
isOverflown() && marque.enable(title)
|
||||
},
|
||||
reset: () => {
|
||||
title.classList.remove('pick')
|
||||
isOverflown() && marque.reset()
|
||||
}
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
_title.reset()
|
||||
// _desc.hide()
|
||||
}
|
||||
const clear = () => _title.reset()
|
||||
|
||||
return {
|
||||
get description() {
|
||||
|
|
@ -132,7 +156,7 @@ const ui = (() => {
|
|||
rootEl.innerHTML = games.list.map(game =>
|
||||
`<div class="menu-item">` +
|
||||
`<div><span>${game.title}</span></div>` +
|
||||
//`<div class="menu-item__info">${game.system}</div>` +
|
||||
`<div class="menu-item__info">${game.system}</div>` +
|
||||
`</div>`)
|
||||
.join('')
|
||||
items = [...rootEl.querySelectorAll('.menu-item')].map(x => item(x))
|
||||
|
|
@ -191,21 +215,14 @@ const select = (index) => {
|
|||
|
||||
scroll.onShift = (delta) => select(games.index + delta)
|
||||
|
||||
let hasTransition = true // needed for cases when MENU_RELEASE called instead MENU_PRESSED
|
||||
|
||||
scroll.onStop = () => {
|
||||
const item = ui.selected
|
||||
if (item) {
|
||||
item.title.pick()
|
||||
item.title.animate()
|
||||
// hasTransition ? (ui.onTransitionEnd = item.description.show) : item.description.show()
|
||||
}
|
||||
item && item.title.pick()
|
||||
}
|
||||
|
||||
sub(MENU_PRESSED, (position) => {
|
||||
if (games.empty()) return
|
||||
ui.onTransitionEnd = ui.NO_TRANSITION
|
||||
hasTransition = false
|
||||
scroll.scroll(scroll.state.DRAG)
|
||||
ui.selected && ui.selected.clear()
|
||||
ui.drag.startPos(position)
|
||||
|
|
@ -215,15 +232,14 @@ sub(MENU_RELEASED, (position) => {
|
|||
if (games.empty()) return
|
||||
ui.drag.stopPos(position)
|
||||
select(ui.roundIndex)
|
||||
hasTransition = !hasTransition
|
||||
scroll.scroll(scroll.state.IDLE)
|
||||
hasTransition = true
|
||||
})
|
||||
|
||||
/**
|
||||
* Game list module.
|
||||
*/
|
||||
export const gameList = {
|
||||
disable: () => ui.selected?.clear(),
|
||||
scroll: (x) => {
|
||||
if (games.empty()) return
|
||||
scroll.scroll(x)
|
||||
|
|
|
|||
|
|
@ -175,8 +175,8 @@ const binding = (key = '', value = '', cb = () => ({})) => {
|
|||
return el;
|
||||
}
|
||||
|
||||
const show = (el) => {
|
||||
el.classList.remove('hidden');
|
||||
const show = (...els) => {
|
||||
els.forEach(el => el.classList.remove('hidden'))
|
||||
}
|
||||
|
||||
const inputN = (key = '', cb = () => ({}), current = 0) => {
|
||||
|
|
@ -201,6 +201,23 @@ const toggle = (el, what) => {
|
|||
what ? show(el) : hide(el)
|
||||
}
|
||||
|
||||
const multiToggle = (elements = [], options = {list: []}) => {
|
||||
if (!options.list.length || !elements.length) return
|
||||
|
||||
let i = 0
|
||||
|
||||
const setText = () => elements.forEach(el => el.innerText = options.list[i].caption)
|
||||
|
||||
const handleClick = () => {
|
||||
options.list[i].cb()
|
||||
i = (i + 1) % options.list.length
|
||||
setText()
|
||||
}
|
||||
|
||||
setText()
|
||||
elements.forEach(el => el.addEventListener('click', handleClick))
|
||||
}
|
||||
|
||||
const fadeIn = async (el, speed = .1) => {
|
||||
el.style.opacity = '0';
|
||||
el.style.display = 'block';
|
||||
|
|
@ -252,6 +269,7 @@ export const gui = {
|
|||
fragment,
|
||||
hide,
|
||||
inputN,
|
||||
multiToggle,
|
||||
panel,
|
||||
select,
|
||||
show,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,56 @@
|
|||
export {joystick} from './joystick.js?v=3';
|
||||
import {
|
||||
REFRESH_INPUT,
|
||||
KB_MOUSE_FLAG,
|
||||
pub,
|
||||
sub
|
||||
} from 'event';
|
||||
|
||||
export {KEY} from './keys.js?v=3';
|
||||
export {keyboard} from './keyboard.js?v=3'
|
||||
export {retropad} from './retropad.js?v=3';
|
||||
export {touch} from './touch.js?v=3';
|
||||
|
||||
import {joystick} from './joystick.js?v=3';
|
||||
import {keyboard} from './keyboard.js?v=3'
|
||||
import {pointer} from './pointer.js?v=3';
|
||||
import {retropad} from './retropad.js?v=3';
|
||||
import {touch} from './touch.js?v=3';
|
||||
|
||||
export {joystick, keyboard, pointer, retropad, touch};
|
||||
|
||||
const input_state = {
|
||||
joystick: true,
|
||||
keyboard: false,
|
||||
pointer: true, // aka mouse
|
||||
retropad: true,
|
||||
touch: true,
|
||||
|
||||
kbm: false,
|
||||
}
|
||||
|
||||
const init = () => {
|
||||
keyboard.init()
|
||||
joystick.init()
|
||||
touch.init()
|
||||
}
|
||||
|
||||
sub(KB_MOUSE_FLAG, () => {
|
||||
input_state.kbm = true
|
||||
pub(REFRESH_INPUT)
|
||||
})
|
||||
|
||||
export const input = {
|
||||
state: input_state,
|
||||
init,
|
||||
retropad: {
|
||||
...retropad,
|
||||
toggle(on = true) {
|
||||
if (on === input_state.retropad) return
|
||||
input_state.retropad = on
|
||||
on ? retropad.enable() : retropad.disable()
|
||||
}
|
||||
},
|
||||
set kbm(v) {
|
||||
input_state.kbm = v
|
||||
},
|
||||
get kbm() {
|
||||
return input_state.kbm
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import {
|
||||
pub,
|
||||
sub,
|
||||
KEYBOARD_TOGGLE_FILTER_MODE,
|
||||
AXIS_CHANGED,
|
||||
DPAD_TOGGLE,
|
||||
KEY_PRESSED,
|
||||
KEY_RELEASED,
|
||||
KEYBOARD_KEY_PRESSED
|
||||
KEYBOARD_KEY_PRESSED,
|
||||
KEYBOARD_KEY_DOWN,
|
||||
KEYBOARD_KEY_UP,
|
||||
KEYBOARD_TOGGLE_FILTER_MODE,
|
||||
} from 'event';
|
||||
import {KEY} from 'input';
|
||||
import {log} from 'log'
|
||||
|
|
@ -47,11 +49,16 @@ const defaultMap = Object.freeze({
|
|||
});
|
||||
|
||||
let keyMap = {};
|
||||
// special mode for changing button bindings in the options
|
||||
let isKeysFilteredMode = true;
|
||||
// if the browser supports Keyboard Lock API (Firefox does not)
|
||||
let hasKeyboardLock = ('keyboard' in navigator) && ('lock' in navigator.keyboard)
|
||||
|
||||
let locked = false
|
||||
|
||||
const remap = (map = {}) => {
|
||||
settings.set(opts.INPUT_KEYBOARD_MAP, map);
|
||||
log.info('Keyboard keys have been remapped')
|
||||
log.debug('Keyboard keys have been remapped')
|
||||
}
|
||||
|
||||
sub(KEYBOARD_TOGGLE_FILTER_MODE, data => {
|
||||
|
|
@ -88,9 +95,16 @@ function onDpadToggle(checked) {
|
|||
}
|
||||
}
|
||||
|
||||
const lock = async (lock) => {
|
||||
locked = lock
|
||||
if (hasKeyboardLock) {
|
||||
lock ? await navigator.keyboard.lock() : navigator.keyboard.unlock()
|
||||
}
|
||||
// if the browser doesn't support keyboard lock, it will be emulated
|
||||
}
|
||||
|
||||
const onKey = (code, evt, state) => {
|
||||
const key = keyMap[code]
|
||||
if (key === undefined) return
|
||||
|
||||
if (dpadState[key] !== undefined) {
|
||||
dpadState[key] = state
|
||||
|
|
@ -103,7 +117,7 @@ const onKey = (code, evt, state) => {
|
|||
return
|
||||
}
|
||||
}
|
||||
pub(evt, {key: key})
|
||||
pub(evt, {key: key, code: code})
|
||||
}
|
||||
|
||||
sub(DPAD_TOGGLE, (data) => onDpadToggle(data.checked));
|
||||
|
|
@ -115,28 +129,35 @@ export const keyboard = {
|
|||
init: () => {
|
||||
keyMap = settings.loadOr(opts.INPUT_KEYBOARD_MAP, defaultMap);
|
||||
const body = document.body;
|
||||
// !to use prevent default as everyone
|
||||
|
||||
body.addEventListener('keyup', e => {
|
||||
e.stopPropagation();
|
||||
if (isKeysFilteredMode) {
|
||||
onKey(e.code, KEY_RELEASED, false)
|
||||
} else {
|
||||
pub(KEYBOARD_KEY_PRESSED, {key: e.code});
|
||||
e.stopPropagation()
|
||||
!hasKeyboardLock && locked && e.preventDefault()
|
||||
|
||||
let lock = locked
|
||||
// hack with Esc up when outside of lock
|
||||
if (e.code === 'Escape') {
|
||||
lock = true
|
||||
}
|
||||
}, false);
|
||||
|
||||
isKeysFilteredMode ?
|
||||
(lock ? pub(KEYBOARD_KEY_UP, e) : onKey(e.code, KEY_RELEASED, false))
|
||||
: pub(KEYBOARD_KEY_PRESSED, {key: e.code})
|
||||
}, false)
|
||||
|
||||
body.addEventListener('keydown', e => {
|
||||
e.stopPropagation();
|
||||
if (isKeysFilteredMode) {
|
||||
onKey(e.code, KEY_PRESSED, true)
|
||||
} else {
|
||||
pub(KEYBOARD_KEY_PRESSED, {key: e.code});
|
||||
}
|
||||
});
|
||||
e.stopPropagation()
|
||||
!hasKeyboardLock && locked && e.preventDefault()
|
||||
|
||||
log.info('[input] keyboard has been initialized');
|
||||
isKeysFilteredMode ?
|
||||
(locked ? pub(KEYBOARD_KEY_DOWN, e) : onKey(e.code, KEY_PRESSED, true)) :
|
||||
pub(KEYBOARD_KEY_PRESSED, {key: e.code})
|
||||
})
|
||||
|
||||
log.info('[input] keyboard has been initialized')
|
||||
},
|
||||
settings: {
|
||||
remap
|
||||
}
|
||||
},
|
||||
lock,
|
||||
}
|
||||
|
|
|
|||
153
web/js/input/pointer.js
Normal file
153
web/js/input/pointer.js
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
// Pointer (aka mouse) stuff
|
||||
import {
|
||||
MOUSE_PRESSED,
|
||||
MOUSE_MOVED,
|
||||
pub
|
||||
} from 'event';
|
||||
import {browser, env} from 'env';
|
||||
|
||||
const hasRawPointer = 'onpointerrawupdate' in window
|
||||
|
||||
const p = {dx: 0, dy: 0}
|
||||
|
||||
const move = (e, cb, single = false) => {
|
||||
// !to fix ff https://github.com/w3c/pointerlock/issues/42
|
||||
if (single) {
|
||||
p.dx = e.movementX
|
||||
p.dy = e.movementY
|
||||
cb(p)
|
||||
} else {
|
||||
const _events = e.getCoalescedEvents?.()
|
||||
if (_events && (hasRawPointer || _events.length > 1)) {
|
||||
for (let i = 0; i < _events.length; i++) {
|
||||
p.dx = _events[i].movementX
|
||||
p.dy = _events[i].movementY
|
||||
cb(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const _track = (el, cb, single) => {
|
||||
const _move = (e) => {
|
||||
move(e, cb, single)
|
||||
}
|
||||
el.addEventListener(hasRawPointer ? 'pointerrawupdate' : 'pointermove', _move)
|
||||
return () => {
|
||||
el.removeEventListener(hasRawPointer ? 'pointerrawupdate' : 'pointermove', _move)
|
||||
}
|
||||
}
|
||||
|
||||
const dpiScaler = () => {
|
||||
let ex = 0
|
||||
let ey = 0
|
||||
let scaled = {dx: 0, dy: 0}
|
||||
return {
|
||||
scale(x, y, src_w, src_h, dst_w, dst_h) {
|
||||
scaled.dx = x / (src_w / dst_w) + ex
|
||||
scaled.dy = y / (src_h / dst_h) + ey
|
||||
|
||||
ex = scaled.dx % 1
|
||||
ey = scaled.dy % 1
|
||||
|
||||
scaled.dx -= ex
|
||||
scaled.dy -= ey
|
||||
|
||||
return scaled
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dpi = dpiScaler()
|
||||
|
||||
const handlePointerMove = (el, cb) => {
|
||||
let w, h = 0
|
||||
let s = false
|
||||
const dw = 640, dh = 480
|
||||
return (p) => {
|
||||
({w, h, s} = cb())
|
||||
pub(MOUSE_MOVED, s ? dpi.scale(p.dx, p.dy, w, h, dw, dh) : p)
|
||||
}
|
||||
}
|
||||
|
||||
const trackPointer = (el, cb) => {
|
||||
let mpu, mpd
|
||||
let noTrack
|
||||
|
||||
// disable coalesced mouse move events
|
||||
const single = true
|
||||
|
||||
// coalesced event are broken since FF 120
|
||||
const isFF = env.getBrowser === browser.firefox
|
||||
|
||||
const pm = handlePointerMove(el, cb)
|
||||
|
||||
return (enabled) => {
|
||||
if (enabled) {
|
||||
!noTrack && (noTrack = _track(el, pm, isFF || single))
|
||||
mpu = pointer.handle.up(el)
|
||||
mpd = pointer.handle.down(el)
|
||||
return
|
||||
}
|
||||
|
||||
mpu?.()
|
||||
mpd?.()
|
||||
noTrack?.()
|
||||
noTrack = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleDown = ((b = {b: null, p: true}) => (e) => {
|
||||
b.b = e.button
|
||||
pub(MOUSE_PRESSED, b)
|
||||
})()
|
||||
|
||||
const handleUp = ((b = {b: null, p: false}) => (e) => {
|
||||
b.b = e.button
|
||||
pub(MOUSE_PRESSED, b)
|
||||
})()
|
||||
|
||||
const autoHide = (el, time = 3000) => {
|
||||
let tm
|
||||
let move
|
||||
const cl = el.classList
|
||||
|
||||
const hide = (force = false) => {
|
||||
cl.add('no-pointer')
|
||||
!force && el.addEventListener('pointermove', move)
|
||||
}
|
||||
|
||||
move = () => {
|
||||
cl.remove('no-pointer')
|
||||
clearTimeout(tm)
|
||||
tm = setTimeout(hide, time)
|
||||
}
|
||||
|
||||
const show = () => {
|
||||
clearTimeout(tm)
|
||||
el.removeEventListener('pointermove', move)
|
||||
cl.remove('no-pointer')
|
||||
}
|
||||
|
||||
return {
|
||||
autoHide: (on) => on ? show() : hide()
|
||||
}
|
||||
}
|
||||
|
||||
export const pointer = {
|
||||
autoHide,
|
||||
lock: async (el) => {
|
||||
await el.requestPointerLock(/*{ unadjustedMovement: true}*/)
|
||||
},
|
||||
track: trackPointer,
|
||||
handle: {
|
||||
down: (el) => {
|
||||
el.onpointerdown = handleDown
|
||||
return () => (el.onpointerdown = null)
|
||||
},
|
||||
up: (el) => {
|
||||
el.onpointerup = handleUp
|
||||
return () => (el.onpointerup = null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -94,7 +94,8 @@ const _getState = () => {
|
|||
const _poll = poll(pollingIntervalMs, sendControllerState)
|
||||
|
||||
export const retropad = {
|
||||
poll: _poll,
|
||||
enable: () => _poll.enable(),
|
||||
disable: () => _poll.disable(),
|
||||
setKeyState,
|
||||
setAxisChanged,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,8 @@ const getKey = (el) => el.dataset.key
|
|||
let dpadMode = true;
|
||||
const deadZone = 0.1;
|
||||
|
||||
let enabled = false
|
||||
|
||||
function onDpadToggle(checked) {
|
||||
if (dpadMode === checked) {
|
||||
return //error?
|
||||
|
|
@ -237,6 +239,8 @@ function handleMenuUp(evt) {
|
|||
|
||||
// Common events
|
||||
function handleWindowMove(event) {
|
||||
if (!enabled) return
|
||||
|
||||
event.preventDefault();
|
||||
handleVpadJoystickMove(event);
|
||||
handleMenuMove(event);
|
||||
|
|
@ -303,6 +307,7 @@ playerSlider.onkeydown = (e) => {
|
|||
*/
|
||||
export const touch = {
|
||||
init: () => {
|
||||
enabled = true
|
||||
// Bind events for menu
|
||||
// TODO change this flow
|
||||
pub(MENU_HANDLER_ATTACHED, {event: 'mousedown', handler: handleMenuDown});
|
||||
|
|
@ -316,10 +321,11 @@ export const touch = {
|
|||
vpadState[getKey(el)] = false;
|
||||
});
|
||||
|
||||
window.addEventListener('mousemove', handleWindowMove);
|
||||
window.addEventListener('pointermove', handleWindowMove);
|
||||
window.addEventListener('touchmove', handleWindowMove, {passive: false});
|
||||
window.addEventListener('mouseup', handleWindowUp);
|
||||
|
||||
log.info('[input] touch input has been initialized');
|
||||
}
|
||||
},
|
||||
toggle: (v) => v === undefined ? (enabled = !enabled) : (enabled = v)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,9 @@ import {
|
|||
import {log} from 'log';
|
||||
|
||||
let connection;
|
||||
let dataChannel;
|
||||
let dataChannel
|
||||
let keyboardChannel
|
||||
let mouseChannel
|
||||
let mediaStream;
|
||||
let candidates = [];
|
||||
let isAnswered = false;
|
||||
|
|
@ -30,6 +32,16 @@ const start = (iceservers) => {
|
|||
log.debug('[rtc] ondatachannel', e.channel.label)
|
||||
e.channel.binaryType = "arraybuffer";
|
||||
|
||||
if (e.channel.label === 'keyboard') {
|
||||
keyboardChannel = e.channel
|
||||
return
|
||||
}
|
||||
|
||||
if (e.channel.label === 'mouse') {
|
||||
mouseChannel = e.channel
|
||||
return
|
||||
}
|
||||
|
||||
dataChannel = e.channel;
|
||||
dataChannel.onopen = () => {
|
||||
log.info('[rtc] the input channel has been opened');
|
||||
|
|
@ -39,7 +51,10 @@ const start = (iceservers) => {
|
|||
if (onData) {
|
||||
dataChannel.onmessage = onData;
|
||||
}
|
||||
dataChannel.onclose = () => log.info('[rtc] the input channel has been closed');
|
||||
dataChannel.onclose = () => {
|
||||
inputReady = false
|
||||
log.info('[rtc] the input channel has been closed')
|
||||
}
|
||||
}
|
||||
connection.oniceconnectionstatechange = ice.onIceConnectionStateChange;
|
||||
connection.onicegatheringstatechange = ice.onIceStateChange;
|
||||
|
|
@ -62,8 +77,16 @@ const stop = () => {
|
|||
connection = null;
|
||||
}
|
||||
if (dataChannel) {
|
||||
dataChannel.close();
|
||||
dataChannel = null;
|
||||
dataChannel.close()
|
||||
dataChannel = null
|
||||
}
|
||||
if (keyboardChannel) {
|
||||
keyboardChannel?.close()
|
||||
keyboardChannel = null
|
||||
}
|
||||
if (mouseChannel) {
|
||||
mouseChannel?.close()
|
||||
mouseChannel = null
|
||||
}
|
||||
candidates = [];
|
||||
log.info('[rtc] WebRTC has been closed');
|
||||
|
|
@ -162,7 +185,9 @@ export const webrtc = {
|
|||
});
|
||||
isFlushing = false;
|
||||
},
|
||||
input: (data) => dataChannel.send(data),
|
||||
keyboard: (data) => keyboardChannel?.send(data),
|
||||
mouse: (data) => mouseChannel?.send(data),
|
||||
input: (data) => inputReady && dataChannel.send(data),
|
||||
isConnected: () => connected,
|
||||
isInputReady: () => inputReady,
|
||||
stats: async () => {
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ const parseURLForRoom = () => {
|
|||
};
|
||||
|
||||
sub(GAME_ROOM_AVAILABLE, data => {
|
||||
room.setId(data.roomId);
|
||||
room.id = data.roomId
|
||||
room.save(data.roomId);
|
||||
}, 1);
|
||||
|
||||
|
|
@ -38,8 +38,10 @@ sub(GAME_ROOM_AVAILABLE, data => {
|
|||
* Game room module.
|
||||
*/
|
||||
export const room = {
|
||||
getId: () => id,
|
||||
setId: (id_) => {
|
||||
get id() {
|
||||
return id
|
||||
},
|
||||
set id(id_) {
|
||||
id = id_;
|
||||
roomLabel.value = id;
|
||||
},
|
||||
|
|
@ -51,7 +53,7 @@ export const room = {
|
|||
localStorage.setItem('roomID', roomIndex);
|
||||
},
|
||||
load: () => localStorage.getItem('roomID'),
|
||||
getLink: () => window.location.href.split('?')[0] + `?id=${encodeURIComponent(room.getId())}`,
|
||||
getLink: () => window.location.href.split('?')[0] + `?id=${encodeURIComponent(room.id)}`,
|
||||
loadMaybe: () => {
|
||||
// localStorage first
|
||||
//roomID = loadRoomID();
|
||||
|
|
|
|||
|
|
@ -1,8 +1,15 @@
|
|||
import {
|
||||
sub,
|
||||
SETTINGS_CHANGED,
|
||||
REFRESH_INPUT,
|
||||
} from 'event';
|
||||
import {env} from 'env';
|
||||
import {input, pointer, keyboard} from 'input';
|
||||
import {opts, settings} from 'settings';
|
||||
import {SETTINGS_CHANGED, sub} from "event";
|
||||
import {env} from "env";
|
||||
import {gui} from 'gui';
|
||||
|
||||
const rootEl = document.getElementById('screen');
|
||||
const rootEl = document.getElementById('screen')
|
||||
const footerEl = document.getElementsByClassName('screen__footer')[0]
|
||||
|
||||
const state = {
|
||||
components: [],
|
||||
|
|
@ -10,30 +17,63 @@ const state = {
|
|||
forceFullscreen: false,
|
||||
}
|
||||
|
||||
const toggle = (component, force) => {
|
||||
component && (state.current = component); // keep the last component
|
||||
state.components.forEach(c => c.toggle(false));
|
||||
state.current?.toggle(force);
|
||||
component && !env.isMobileDevice && !state.current?.noFullscreen && state.forceFullscreen && fullscreen();
|
||||
const toggle = async (component, force) => {
|
||||
component && (state.current = component) // keep the last component
|
||||
state.components.forEach(c => c.toggle(false))
|
||||
state.current?.toggle(force)
|
||||
state.forceFullscreen && fullscreen(true)
|
||||
}
|
||||
|
||||
const init = () => {
|
||||
state.forceFullscreen = settings.loadOr(opts.FORCE_FULLSCREEN, false);
|
||||
state.forceFullscreen = settings.loadOr(opts.FORCE_FULLSCREEN, false)
|
||||
sub(SETTINGS_CHANGED, () => {
|
||||
state.forceFullscreen = settings.get()[opts.FORCE_FULLSCREEN];
|
||||
});
|
||||
state.forceFullscreen = settings.get()[opts.FORCE_FULLSCREEN]
|
||||
})
|
||||
}
|
||||
|
||||
const cursor = pointer.autoHide(rootEl, 2000)
|
||||
|
||||
const trackPointer = pointer.track(rootEl, () => {
|
||||
const display = state.current;
|
||||
return {...display.video.size, s: !!display?.hasDisplay}
|
||||
})
|
||||
|
||||
const fullscreen = () => {
|
||||
let h = parseFloat(getComputedStyle(rootEl, null)
|
||||
.height
|
||||
.replace('px', '')
|
||||
)
|
||||
env.display().toggleFullscreen(h !== window.innerHeight, rootEl);
|
||||
if (state.current?.noFullscreen) return
|
||||
|
||||
let h = parseFloat(getComputedStyle(rootEl, null).height.replace('px', ''))
|
||||
env.display().toggleFullscreen(h !== window.innerHeight, rootEl)
|
||||
}
|
||||
|
||||
rootEl.addEventListener('fullscreenchange', () => {
|
||||
state.current?.onFullscreen?.(document.fullscreenElement !== null)
|
||||
const controls = async (locked = false) => {
|
||||
if (!state.current?.hasDisplay) return
|
||||
if (env.isMobileDevice) return
|
||||
if (!input.kbm) return
|
||||
|
||||
if (locked) {
|
||||
await pointer.lock(rootEl)
|
||||
}
|
||||
|
||||
// oof, remove hover:hover when the pointer is forcibly locked,
|
||||
// leaving the element in the hovered state
|
||||
locked ? footerEl.classList.remove('hover') : footerEl.classList.add('hover')
|
||||
|
||||
trackPointer(locked)
|
||||
await keyboard.lock(locked)
|
||||
input.retropad.toggle(!locked)
|
||||
}
|
||||
|
||||
rootEl.addEventListener('fullscreenchange', async () => {
|
||||
const fs = document.fullscreenElement !== null
|
||||
|
||||
cursor.autoHide(!fs)
|
||||
gui.toggle(footerEl, fs)
|
||||
await controls(fs)
|
||||
state.current?.onFullscreen?.(fs)
|
||||
})
|
||||
|
||||
sub(REFRESH_INPUT, async () => {
|
||||
await controls(document.fullscreenElement !== null)
|
||||
})
|
||||
|
||||
export const screen = {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ export const opts = {
|
|||
MIRROR_SCREEN: 'mirror.screen',
|
||||
VOLUME: 'volume',
|
||||
FORCE_FULLSCREEN: 'force.fullscreen',
|
||||
SHOW_PING: 'show.ping',
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -229,6 +228,14 @@ const set = (key, value, updateProvider = true) => {
|
|||
}
|
||||
}
|
||||
|
||||
const changed = (key, obj, key2) => {
|
||||
if (!store.settings.hasOwnProperty(key)) return
|
||||
const newValue = store.settings[key]
|
||||
const changed = newValue !== obj[key2]
|
||||
changed && (obj[key2] = newValue)
|
||||
return changed
|
||||
}
|
||||
|
||||
const _reset = () => {
|
||||
for (let _option of Object.keys(_defaults)) {
|
||||
const value = _defaults[_option];
|
||||
|
|
@ -340,6 +347,7 @@ export const settings = {
|
|||
getStore,
|
||||
get,
|
||||
set,
|
||||
changed,
|
||||
remove,
|
||||
import: _import,
|
||||
export: _export,
|
||||
|
|
@ -488,6 +496,8 @@ const render = function () {
|
|||
case opts.INPUT_KEYBOARD_MAP:
|
||||
_option(data).withName('Keyboard bindings')
|
||||
.withClass('keyboard-bindings')
|
||||
.withDescription(
|
||||
'Bindings for RetroPad. There is an alternate ESC key [Shift+`] (tilde) for cores with keyboard+mouse controls (DosBox)')
|
||||
.add(Object.keys(value).map(k => gui.binding(value[k], k, onKeyBindingChange)))
|
||||
.build();
|
||||
break;
|
||||
|
|
@ -500,7 +510,6 @@ const render = function () {
|
|||
case opts.VOLUME:
|
||||
_option(data).withName('Volume (%)')
|
||||
.add(gui.inputN(k, onChange, value))
|
||||
.restartNeeded()
|
||||
.build()
|
||||
break;
|
||||
case opts.FORCE_FULLSCREEN:
|
||||
|
|
@ -511,12 +520,6 @@ const render = function () {
|
|||
.add(gui.checkbox(k, onChange, value, 'Enabled', 'settings__option-checkbox'))
|
||||
.build()
|
||||
break;
|
||||
case opts.SHOW_PING:
|
||||
_option(data).withName('Show ping')
|
||||
.withDescription('Always display ping info on the screen')
|
||||
.add(gui.checkbox(k, onChange, value, 'Enabled', 'settings__option-checkbox'))
|
||||
.build()
|
||||
break;
|
||||
default:
|
||||
_option(data).withName(k).add(value).build();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,19 +110,23 @@ const graph = (parent, opts = {
|
|||
* Get cached module UI.
|
||||
*
|
||||
* HTML:
|
||||
* <div><div>LABEL</div><span>VALUE</span>[<span><canvas/><span>]</div>
|
||||
* `<div><div>LABEL</div><span>VALUE</span>[<span><canvas/><span>]</div>`
|
||||
*
|
||||
* @param label The name of the stat to show.
|
||||
* @param nan A value to show when zero.
|
||||
* @param withGraph True if to draw a graph.
|
||||
* @param postfix Supposed to be the name of the stat passed as a function.
|
||||
* @param cl Class of the UI div element.
|
||||
* @returns {{el: HTMLDivElement, update: function}}
|
||||
*/
|
||||
const moduleUi = (label = '', withGraph = false, postfix = () => 'ms') => {
|
||||
const moduleUi = (label = '', nan = '', withGraph = false, postfix = () => 'ms', cl = '') => {
|
||||
const ui = document.createElement('div'),
|
||||
_label = document.createElement('div'),
|
||||
_value = document.createElement('span');
|
||||
ui.append(_label, _value);
|
||||
|
||||
cl && ui.classList.add(cl)
|
||||
|
||||
let postfix_ = postfix;
|
||||
|
||||
let _graph;
|
||||
|
|
@ -139,7 +143,7 @@ const moduleUi = (label = '', withGraph = false, postfix = () => 'ms') => {
|
|||
const update = (value) => {
|
||||
if (_graph) _graph.add(value);
|
||||
// 203 (333) ms
|
||||
_value.textContent = `${value < 1 ? '<1' : value} ${_graph ? `(${_graph.max()}) ` : ''}${postfix_(value)}`;
|
||||
_value.textContent = `${value < 1 ? nan : value}${_graph ? `(${_graph.max()}) ` : ''}${postfix_(value)}`;
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
|
|
@ -157,7 +161,7 @@ const module = (mod) => {
|
|||
enable: () => ({}),
|
||||
...mod,
|
||||
_disable: function () {
|
||||
mod.val = 0;
|
||||
// mod.val = 0;
|
||||
mod.disable && mod.disable();
|
||||
mod.mui && mod.mui.clear();
|
||||
},
|
||||
|
|
|
|||
212
web/js/stream.js
212
web/js/stream.js
|
|
@ -1,13 +1,14 @@
|
|||
import {
|
||||
sub,
|
||||
APP_VIDEO_CHANGED,
|
||||
SETTINGS_CHANGED
|
||||
} from 'event' ;
|
||||
import {gui} from 'gui';
|
||||
SETTINGS_CHANGED,
|
||||
TRANSFORM_CHANGE
|
||||
} from 'event';
|
||||
import {log} from 'log';
|
||||
import {opts, settings} from 'settings';
|
||||
|
||||
const videoEl = document.getElementById('stream');
|
||||
const videoEl = document.getElementById('stream')
|
||||
const mirrorEl = document.getElementById('mirror-stream')
|
||||
|
||||
const options = {
|
||||
volume: 0.5,
|
||||
|
|
@ -21,151 +22,151 @@ const state = {
|
|||
timerId: null,
|
||||
w: 0,
|
||||
h: 0,
|
||||
aspect: 4 / 3
|
||||
aspect: 4 / 3,
|
||||
ready: false
|
||||
}
|
||||
|
||||
const mute = (mute) => (videoEl.muted = mute)
|
||||
|
||||
const _stream = () => {
|
||||
const play = () => {
|
||||
videoEl.play()
|
||||
.then(() => log.info('Media can autoplay'))
|
||||
.catch(error => {
|
||||
log.error('Media failed to play', error);
|
||||
});
|
||||
.then(() => {
|
||||
state.ready = true
|
||||
videoEl.poster = ''
|
||||
useCustomScreen(options.mirrorMode === 'mirror')
|
||||
})
|
||||
.catch(error => log.error('Can\'t autoplay', error))
|
||||
}
|
||||
|
||||
const toggle = (show) => state.screen.toggleAttribute('hidden', show === undefined ? show : !show)
|
||||
|
||||
videoEl.onerror = (e) => {
|
||||
// video playback failed - show a message saying why
|
||||
switch (e.target.error.code) {
|
||||
case e.target.error.MEDIA_ERR_ABORTED:
|
||||
log.error('You aborted the video playback.');
|
||||
break;
|
||||
case e.target.error.MEDIA_ERR_NETWORK:
|
||||
log.error('A network error caused the video download to fail part-way.');
|
||||
break;
|
||||
case e.target.error.MEDIA_ERR_DECODE:
|
||||
log.error('The video playback was aborted due to a corruption problem or because the video used features your browser did not support.');
|
||||
break;
|
||||
case e.target.error.MEDIA_ERR_SRC_NOT_SUPPORTED:
|
||||
log.error('The video could not be loaded, either because the server or network failed or because the format is not supported.');
|
||||
break;
|
||||
default:
|
||||
log.error('An unknown video error occurred.');
|
||||
break;
|
||||
}
|
||||
};
|
||||
// Track resize even when the underlying media stream changes its video size
|
||||
videoEl.addEventListener('resize', () => {
|
||||
recalculateSize()
|
||||
if (state.screen === videoEl) return
|
||||
|
||||
state.screen.setAttribute('width', videoEl.videoWidth)
|
||||
state.screen.setAttribute('height', videoEl.videoHeight)
|
||||
})
|
||||
|
||||
videoEl.addEventListener('loadedmetadata', () => {
|
||||
if (state.screen !== videoEl) {
|
||||
state.screen.setAttribute('width', videoEl.videoWidth);
|
||||
state.screen.setAttribute('height', videoEl.videoHeight);
|
||||
}
|
||||
}, false);
|
||||
videoEl.addEventListener('loadstart', () => {
|
||||
videoEl.volume = options.volume;
|
||||
videoEl.poster = options.poster;
|
||||
}, false);
|
||||
videoEl.addEventListener('play', () => {
|
||||
videoEl.poster = '';
|
||||
useCustomScreen(options.mirrorMode === 'mirror');
|
||||
}, false);
|
||||
videoEl.volume = options.volume / 100
|
||||
videoEl.poster = options.poster
|
||||
})
|
||||
|
||||
const screenToAspect = (el) => {
|
||||
const w = window.screen.width ?? window.innerWidth;
|
||||
const hh = el.innerHeight || el.clientHeight || 0;
|
||||
const dw = (w - hh * state.aspect) / 2
|
||||
videoEl.style.padding = `0 ${dw}px`
|
||||
}
|
||||
videoEl.onfocus = () => videoEl.blur()
|
||||
videoEl.onerror = (e) => log.error('Playback error', e)
|
||||
|
||||
const onFullscreen = (y) => {
|
||||
if (y) {
|
||||
screenToAspect(document.fullscreenElement);
|
||||
// chrome bug
|
||||
const onFullscreen = (fullscreen) => {
|
||||
const el = document.fullscreenElement
|
||||
|
||||
if (fullscreen) {
|
||||
// timeout is due to a chrome bug
|
||||
setTimeout(() => {
|
||||
screenToAspect(document.fullscreenElement)
|
||||
// aspect ratio calc
|
||||
const w = window.screen.width ?? window.innerWidth
|
||||
const hh = el.innerHeight || el.clientHeight || 0
|
||||
const dw = (w - hh * state.aspect) / 2
|
||||
state.screen.style.padding = `0 ${dw}px`
|
||||
state.screen.classList.toggle('with-footer')
|
||||
}, 1)
|
||||
} else {
|
||||
videoEl.style.padding = '0'
|
||||
state.screen.style.padding = '0'
|
||||
state.screen.classList.toggle('with-footer')
|
||||
}
|
||||
videoEl.classList.toggle('no-media-controls', !!y)
|
||||
|
||||
if (el === videoEl) {
|
||||
videoEl.classList.toggle('no-media-controls', !fullscreen)
|
||||
videoEl.blur()
|
||||
}
|
||||
}
|
||||
|
||||
const vs = {w: 1, h: 1}
|
||||
|
||||
const recalculateSize = () => {
|
||||
const fullscreen = document.fullscreenElement !== null
|
||||
const {aspect, screen} = state
|
||||
|
||||
let width, height
|
||||
if (fullscreen) {
|
||||
// we can't get the real <video> size on the screen without the black bars,
|
||||
// so we're using one side (without the bar) for the length calc of another
|
||||
const horizontal = videoEl.videoWidth > videoEl.videoHeight
|
||||
width = horizontal ? aspect * screen.offsetHeight : screen.offsetWidth
|
||||
height = horizontal ? screen.offsetHeight : aspect * screen.offsetWidth
|
||||
} else {
|
||||
({width, height} = screen.getBoundingClientRect())
|
||||
}
|
||||
|
||||
vs.w = width
|
||||
vs.h = height
|
||||
}
|
||||
|
||||
const useCustomScreen = (use) => {
|
||||
if (use) {
|
||||
if (videoEl.paused || videoEl.ended) return;
|
||||
if (videoEl.paused || videoEl.ended) return
|
||||
|
||||
let id = state.screen.getAttribute('id');
|
||||
if (id === 'canvas-mirror') return;
|
||||
if (state.screen === mirrorEl) return
|
||||
|
||||
const canvas = gui.create('canvas');
|
||||
canvas.setAttribute('id', 'canvas-mirror');
|
||||
canvas.setAttribute('hidden', '');
|
||||
canvas.setAttribute('width', videoEl.videoWidth);
|
||||
canvas.setAttribute('height', videoEl.videoHeight);
|
||||
canvas.style['image-rendering'] = 'pixelated';
|
||||
canvas.style.width = '100%'
|
||||
canvas.style.height = '100%'
|
||||
canvas.classList.add('game-screen');
|
||||
toggle(false)
|
||||
state.screen = mirrorEl
|
||||
state.screen.setAttribute('width', videoEl.videoWidth)
|
||||
state.screen.setAttribute('height', videoEl.videoHeight)
|
||||
|
||||
// stretch depending on the video orientation
|
||||
const isPortrait = videoEl.videoWidth < videoEl.videoHeight;
|
||||
canvas.style.width = isPortrait ? 'auto' : canvas.style.width;
|
||||
// canvas.style.height = isPortrait ? canvas.style.height : 'auto';
|
||||
const isPortrait = videoEl.videoWidth < videoEl.videoHeight
|
||||
state.screen.style.width = isPortrait ? 'auto' : videoEl.videoWidth
|
||||
|
||||
let surface = canvas.getContext('2d');
|
||||
videoEl.parentNode.insertBefore(canvas, videoEl.nextSibling);
|
||||
toggle(false)
|
||||
state.screen = canvas
|
||||
toggle(true)
|
||||
let surface = state.screen.getContext('2d')
|
||||
state.ready && toggle(true)
|
||||
state.timerId = setInterval(function () {
|
||||
if (videoEl.paused || videoEl.ended || !surface) return;
|
||||
surface.drawImage(videoEl, 0, 0);
|
||||
}, options.mirrorUpdateRate);
|
||||
if (videoEl.paused || videoEl.ended || !surface) return
|
||||
surface.drawImage(videoEl, 0, 0)
|
||||
}, options.mirrorUpdateRate)
|
||||
} else {
|
||||
clearInterval(state.timerId);
|
||||
let mirror = state.screen;
|
||||
state.screen = videoEl;
|
||||
toggle(true);
|
||||
if (mirror !== videoEl) {
|
||||
mirror.parentNode.removeChild(mirror);
|
||||
}
|
||||
clearInterval(state.timerId)
|
||||
toggle(false)
|
||||
state.screen = videoEl
|
||||
state.ready && toggle(true)
|
||||
}
|
||||
}
|
||||
|
||||
const init = () => {
|
||||
options.mirrorMode = settings.loadOr(opts.MIRROR_SCREEN, 'none');
|
||||
options.volume = settings.loadOr(opts.VOLUME, 50) / 100;
|
||||
options.mirrorMode = settings.loadOr(opts.MIRROR_SCREEN, 'none')
|
||||
options.volume = settings.loadOr(opts.VOLUME, 50)
|
||||
sub(SETTINGS_CHANGED, () => {
|
||||
const s = settings.get();
|
||||
const newValue = s[opts.MIRROR_SCREEN];
|
||||
if (newValue !== options.mirrorMode) {
|
||||
useCustomScreen(newValue === 'mirror');
|
||||
options.mirrorMode = newValue;
|
||||
if (settings.changed(opts.MIRROR_SCREEN, options, 'mirrorMode')) {
|
||||
useCustomScreen(options.mirrorMode === 'mirror')
|
||||
}
|
||||
});
|
||||
if (settings.changed(opts.VOLUME, options, 'volume')) {
|
||||
videoEl.volume = options.volume / 100
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const fit = 'contain'
|
||||
|
||||
sub(APP_VIDEO_CHANGED, (payload) => {
|
||||
const {w, h, a, s} = payload
|
||||
|
||||
const scale = !s ? 1 : s;
|
||||
const ww = w * scale;
|
||||
const hh = h * scale;
|
||||
const scale = !s ? 1 : s
|
||||
const ww = w * scale
|
||||
const hh = h * scale
|
||||
|
||||
state.aspect = a
|
||||
|
||||
const a2 = (ww / hh).toFixed(6)
|
||||
|
||||
state.screen.style['object-fit'] = a > 1 && a.toFixed(6) !== a2 ? 'fill' : fit
|
||||
state.screen.style['object-fit'] = a > 1 && a.toFixed(6) !== a2 ? 'fill' : 'contain'
|
||||
state.h = hh
|
||||
state.w = Math.floor(hh * a)
|
||||
state.screen.setAttribute('width', '' + ww)
|
||||
state.screen.setAttribute('height', '' + hh)
|
||||
state.screen.style.aspectRatio = '' + state.aspect
|
||||
recalculateSize()
|
||||
})
|
||||
|
||||
sub(TRANSFORM_CHANGE, () => {
|
||||
// cache stream element size when the interface is transformed
|
||||
recalculateSize()
|
||||
})
|
||||
|
||||
/**
|
||||
|
|
@ -176,10 +177,15 @@ sub(APP_VIDEO_CHANGED, (payload) => {
|
|||
*/
|
||||
export const stream = {
|
||||
audio: {mute},
|
||||
video: {el: videoEl},
|
||||
play: _stream,
|
||||
video: {
|
||||
el: videoEl,
|
||||
get size() {
|
||||
return vs
|
||||
},
|
||||
},
|
||||
play,
|
||||
toggle,
|
||||
useCustomScreen,
|
||||
hasDisplay: true,
|
||||
init,
|
||||
onFullscreen,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue