First public release

This commit is contained in:
redphx 2022-04-15 17:47:05 +07:00
parent 963cae3756
commit 64333fdb4a
14 changed files with 1580 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.vscode/
__pycache__/
config.cfg

306
dance.py Normal file
View File

@ -0,0 +1,306 @@
import asyncio
import json
import logging
import re
import socket
import time
from configparser import ConfigParser
from enum import Enum
import hid
from aiohttp import WSMsgType, web
from pyjoycon import ButtonEventJoyCon, JoyCon
from pyjoycon.constants import JOYCON_PRODUCT_IDS, JOYCON_VENDOR_ID
from joydance import JoyDance, PairingState
from joydance.constants import DEFAULT_CONFIG, JOYDANCE_VERSION
logging.getLogger('asyncio').setLevel(logging.WARNING)
class WsCommand(Enum):
GET_JOYCON_LIST = 'get_joycon_list'
CONNECT_JOYCON = 'connect_joycon'
DISCONNECT_JOYCON = 'disconnect_joycon'
UPDATE_JOYCON_STATE = 'update_joycon_state'
class PairingMethod(Enum):
DEFAULT = 'default'
FAST = 'fast'
REGEX_PAIRING_CODE = re.compile(r'^\d{6}$')
REGEX_LOCAL_IP_ADDRESS = re.compile(r'^192\.168\.((\d{1,2}|1\d\d|2[0-4]\d|25[0-5])\.)(\d{1,2}|1\d\d|2[0-4]\d|25[0-5])$')
async def get_device_ids():
devices = hid.enumerate(JOYCON_VENDOR_ID, 0)
out = []
for device in devices:
vendor_id = device['vendor_id']
product_id = device['product_id']
product_string = device['product_string']
serial = device.get('serial') or device.get('serial_number')
if product_id not in JOYCON_PRODUCT_IDS:
continue
if not product_string:
continue
out.append({
'vendor_id': vendor_id,
'product_id': product_id,
'serial': serial,
'product_string': product_string,
})
return out
async def get_joycon_list(app):
joycons = []
devices = await get_device_ids()
for dev in devices:
if dev['serial'] in app['joycons_info']:
info = app['joycons_info'][dev['serial']]
else:
joycon = JoyCon(dev['vendor_id'], dev['product_id'], dev['serial'])
# Wait for initial data
for _ in range(3):
time.sleep(0.05)
battery_level = joycon.get_battery_level()
if battery_level > 0:
break
color = '#%02x%02x%02x' % joycon.color_body
joycon.__del__()
info = {
'vendor_id': dev['vendor_id'],
'product_id': dev['product_id'],
'serial': dev['serial'],
'name': dev['product_string'],
'color': color,
'battery_level': battery_level,
'is_left': joycon.is_left(),
'state': PairingState.IDLE.value,
'pairing_code': '',
}
app['joycons_info'][dev['serial']] = info
joycons.append(info)
return sorted(joycons, key=lambda x: (x['name'], x['color'], x['serial']))
async def connect_joycon(app, ws, data):
async def on_joydance_state_changed(serial, state):
print(serial, state)
app['joycons_info'][serial]['state'] = state.value
try:
await ws_send_response(ws, WsCommand.UPDATE_JOYCON_STATE, app['joycons_info'][serial])
except Exception as e:
print(e)
print(data)
serial = data['joycon_serial']
product_id = app['joycons_info'][serial]['product_id']
vendor_id = app['joycons_info'][serial]['vendor_id']
pairing_method = data['pairing_method']
host_ip_addr = data['host_ip_addr']
console_ip_addr = data['console_ip_addr']
pairing_code = data['pairing_code']
if not is_valid_pairing_method(pairing_method):
return
if pairing_method == PairingMethod.DEFAULT.value:
if not is_valid_ip_address(host_ip_addr) or not is_valid_pairing_code(pairing_code):
return
if pairing_method == PairingMethod.FAST.value and not is_valid_ip_address(console_ip_addr):
return
config_parser = parse_config()
config = dict(config_parser.items('joydance'))
config['pairing_code'] = pairing_code
config['pairing_method'] = pairing_method
config['host_ip_addr'] = host_ip_addr
config['console_ip_addr'] = console_ip_addr
config_parser['joydance'] = config
save_config(config_parser)
app['joycons_info'][serial]['pairing_code'] = pairing_code
joycon = ButtonEventJoyCon(vendor_id, product_id, serial)
if pairing_method == PairingMethod.DEFAULT.value:
console_ip_addr = None
joydance = JoyDance(
joycon,
pairing_code=pairing_code,
host_ip_addr=host_ip_addr,
console_ip_addr=console_ip_addr,
on_state_changed=on_joydance_state_changed,
accel_acquisition_freq_hz=config['accel_acquisition_freq_hz'],
accel_acquisition_latency=config['accel_acquisition_latency'],
accel_max_range=config['accel_max_range'],
)
app['joydance_connections'][serial] = joydance
asyncio.create_task(joydance.pair())
async def disconnect_joycon(app, ws, data):
print(data)
serial = data['joycon_serial']
joydance = app['joydance_connections'][serial]
app['joycons_info'][serial]['state'] = PairingState.IDLE
await joydance.disconnect()
try:
await ws_send_response(ws, WsCommand.UPDATE_JOYCON_STATE, {
'joycon_serial': serial,
'state': PairingState.IDLE.value,
})
except Exception:
pass
def parse_config():
parser = ConfigParser()
parser.read('config.cfg')
if 'joydance' not in parser:
parser['joydance'] = DEFAULT_CONFIG
else:
tmp_config = DEFAULT_CONFIG.copy()
for key in tmp_config:
if key in parser['joydance']:
val = parser['joydance'][key]
if key == 'pairing_method':
if not is_valid_pairing_method(val):
val = PairingMethod.DEFAULT.value
elif key == 'host_ip_addr' or key == 'console_ip_addr':
if not(is_valid_ip_address(val)):
val = ''
elif key == 'pairing_code':
if not is_valid_pairing_code(val):
val = ''
elif key.startswith('accel_'):
try:
val = int(val)
except Exception:
val = DEFAULT_CONFIG[key]
tmp_config[key] = val
parser['joydance'] = tmp_config
if not parser['joydance']['host_ip_addr']:
host_ip_addr = get_host_ip()
if host_ip_addr:
parser['joydance']['host_ip_addr'] = host_ip_addr
save_config(parser)
return parser
def is_valid_pairing_code(val):
return re.match(REGEX_PAIRING_CODE, val) is not None
def is_valid_ip_address(val):
return re.match(REGEX_LOCAL_IP_ADDRESS, val) is not None
def is_valid_pairing_method(val):
return val in [PairingMethod.DEFAULT.value, PairingMethod.FAST.value]
def get_host_ip():
try:
for ip in socket.gethostbyname_ex(socket.gethostname())[2]:
if ip.startswith('192.168'):
return ip
except Exception:
pass
return None
def save_config(parser):
with open('config.cfg', 'w') as fp:
parser.write(fp)
async def html_handler(request):
config = dict((parse_config()).items('joydance'))
with open('static/index.html', 'r') as f:
html = f.read()
html = html.replace('[[CONFIG]]', json.dumps(config))
html = html.replace('[[VERSION]]', JOYDANCE_VERSION)
return web.Response(text=html, content_type='text/html')
async def ws_send_response(ws, cmd, data):
resp = {
'cmd': 'resp_' + cmd.value,
'data': data,
}
await ws.send_json(resp)
async def websocket_handler(request):
ws = web.WebSocketResponse()
await ws.prepare(request)
async for msg in ws:
if msg.type == WSMsgType.TEXT:
msg = msg.json()
try:
cmd = WsCommand(msg['cmd'])
except ValueError:
print('Invalid cmd:', msg['cmd'])
continue
if cmd == WsCommand.GET_JOYCON_LIST:
joycon_list = await get_joycon_list(request.app)
await ws_send_response(ws, cmd, joycon_list)
elif cmd == WsCommand.CONNECT_JOYCON:
await connect_joycon(request.app, ws, msg['data'])
await ws_send_response(ws, cmd, {})
elif cmd == WsCommand.DISCONNECT_JOYCON:
await disconnect_joycon(request.app, ws, msg['data'])
await ws_send_response(ws, cmd, {})
elif msg.type == WSMsgType.ERROR:
print('ws connection closed with exception %s' %
ws.exception())
return ws
def favicon_handler(request):
return web.FileResponse('static/favicon.png')
app = web.Application()
app['joydance_connections'] = {}
app['joycons_info'] = {}
app.add_routes([
web.get('/', html_handler),
web.get('/favicon.png', favicon_handler),
web.get('/ws', websocket_handler),
web.static('/css', 'static/css'),
web.static('/js', 'static/js'),
])
web.run_app(app, port=32623)

413
joydance/__init__.py Normal file
View File

@ -0,0 +1,413 @@
import asyncio
import json
import random
import socket
import ssl
import time
from enum import Enum
import aiohttp
import websockets
from .constants import (ACCEL_ACQUISITION_FREQ_HZ, ACCEL_ACQUISITION_LATENCY,
ACCEL_MAX_RANGE, ACCEL_SEND_RATE, JOYCON_UPDATE_RATE,
SHORTCUT_MAPPING, UBI_APP_ID, UBI_SKU_ID,
WS_SUBPROTOCOL, Command, JoyConButton)
class PairingState(Enum):
IDLE = 0
GETTING_TOKEN = 1
PAIRING = 2
CONNECTING = 3
CONNECTED = 4
DISCONNECTING = 5
DISCONNECTED = 10
ERROR_JOYCON = 101
ERROR_CONNECTION = 102
ERROR_INVALID_PAIRING_CODE = 103
ERROR_PUNCH_PAIRING = 104
ERROR_HOLE_PUNCHING = 105
ERROR_CONSOLE_CONNECTION = 106
class JoyDance:
def __init__(
self,
joycon,
pairing_code=None,
host_ip_addr=None,
console_ip_addr=None,
accel_acquisition_freq_hz=ACCEL_ACQUISITION_FREQ_HZ,
accel_acquisition_latency=ACCEL_ACQUISITION_LATENCY,
accel_max_range=ACCEL_MAX_RANGE,
on_state_changed=None):
self.joycon = joycon
self.joycon_is_left = joycon.is_left()
if on_state_changed:
self.on_state_changed = on_state_changed
self.pairing_code = pairing_code
self.host_ip_addr = host_ip_addr
self.console_ip_addr = console_ip_addr
self.host_port = self.get_random_port()
self.accel_acquisition_freq_hz = accel_acquisition_freq_hz
self.accel_acquisition_latency = accel_acquisition_latency
self.accel_max_range = accel_max_range
self.number_of_accels_sent = 0
self.should_start_accelerometer = False
self.is_input_allowed = False
self.available_shortcuts = set()
self.ws = None
self.disconnected = False
self.headers = {
'Ubi-AppId': UBI_APP_ID,
'X-SkuId': UBI_SKU_ID,
}
self.console_conn = None
def get_random_port(self):
''' Randomize a port number, to be used in hole_punching() later '''
return random.randrange(39000, 39999)
async def on_state_changed(state):
pass
async def get_access_token(self):
''' Log in using a guest account, pre-defined by Ubisoft '''
headers = {
'Authorization': 'UbiMobile_v1 t=NTNjNWRjZGMtZjA2Yy00MTdmLWJkMjctOTNhZTcxNzU1OTkyOlcwM0N5eGZldlBTeFByK3hSa2hhQ05SMXZtdz06UjNWbGMzUmZaVzB3TjJOYTpNakF5TVMweE1DMHlOMVF3TVRvME5sbz0=',
'Ubi-AppId': UBI_APP_ID,
'User-Agent': 'UbiServices_SDK_Unity_Light_Mobile_2018.Release.16_ANDROID64_dynamic',
'Ubi-RequestedPlatformType': 'ubimobile',
'Content-Type': 'application/json',
}
async with aiohttp.ClientSession(headers=headers) as session:
async with session.post('https://public-ubiservices.ubi.com/v1/profiles/sessions', json={}, ssl=False) as resp:
if resp.status != 200:
await self.on_state_changed(self.joycon.serial, PairingState.ERROR_CONNECTION)
raise Exception('ERROR: Couldn\'t get access token!')
# Add ticket to headers
json_body = await resp.json()
self.headers['Authorization'] = 'Ubi_v1 ' + json_body['ticket']
async def send_pairing_code(self):
''' Send pairing code to JD server '''
url = 'https://prod.just-dance.com/sessions/v1/pairing-info'
async with aiohttp.ClientSession(headers=self.headers) as session:
async with session.get(url, params={'code': self.pairing_code}, ssl=False) as resp:
if resp.status != 200:
await self.on_state_changed(self.joycon.serial, PairingState.ERROR_INVALID_PAIRING_CODE)
raise Exception('ERROR: Invalid pairing code!')
json_body = await resp.json()
self.pairing_url = json_body['pairingUrl'].replace('https://', 'wss://') + 'smartphone'
self.requires_punch_pairing = json_body.get('requiresPunchPairing', False)
async def send_initiate_punch_pairing(self):
''' Tell console which IP address & port to connect to '''
url = 'https://prod.just-dance.com/sessions/v1/initiate-punch-pairing'
json_payload = {
'pairingCode': self.pairing_code,
'mobileIP': self.host_ip_addr,
'mobilePort': self.host_port,
}
async with aiohttp.ClientSession(headers=self.headers) as session:
async with session.post(url, json=json_payload, ssl=False) as resp:
body = await resp.text()
if body != 'OK':
await self.on_state_changed(self.joycon.serial, PairingState.ERROR_PUNCH_PAIRING)
raise Exception('ERROR: Couldn\'t initiate punch pairing!')
async def hole_punching(self):
''' Open a port on this machine so the console can connect to it '''
try:
conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
conn.bind(('0.0.0.0', self.host_port))
conn.listen(5)
# Accept incoming connection from console
console_conn, addr = conn.accept()
self.console_conn = console_conn
print('Connected with {}:{}'.format(addr[0], addr[1]))
except Exception as e:
await self.on_state_changed(self.joycon.serial, PairingState.ERROR_HOLE_PUNCHING)
raise e
async def send_message(self, __class, data={}):
''' Send JSON message to server '''
if __class != 'JD_PhoneScoringData':
print('>>>', __class, data)
msg = {'root': {'__class': __class}}
if data:
msg['root'].update(data)
# Remove extra spaces from JSON to reduce size
try:
await self.ws.send(json.dumps(msg, separators=(',', ':')))
except Exception:
await self.disconnect(close_ws=False)
async def on_message(self, message):
message = json.loads(message)
print('<<<', message)
__class = message['__class']
if __class == 'JD_PhoneDataCmdHandshakeContinue':
await self.send_message('JD_PhoneDataCmdSync', {'phoneID': message['phoneID']})
elif __class == 'JD_PhoneDataCmdSyncEnd':
await self.send_message('JD_PhoneDataCmdSyncEnd', {'phoneID': message['phoneID']})
await self.on_state_changed(self.joycon.serial, PairingState.CONNECTED)
elif __class == 'JD_EnableAccelValuesSending_ConsoleCommandData':
self.should_start_accelerometer = True
self.number_of_accels_sent = 0
elif __class == 'JD_DisableAccelValuesSending_ConsoleCommandData':
self.should_start_accelerometer = False
elif __class == 'InputSetup_ConsoleCommandData':
if message.get('isEnabled', 0) == 1:
self.is_input_allowed = True
elif __class == 'EnableCarousel_ConsoleCommandData':
if message.get('isEnabled', 0) == 1:
self.is_input_allowed = True
elif __class == 'JD_EnableLobbyStartbutton_ConsoleCommandData':
if message.get('isEnabled', 0) == 1:
self.is_input_allowed = True
elif __class == 'ShortcutSetup_ConsoleCommandData':
if message.get('isEnabled', 0) == 1:
self.is_input_allowed = True
elif __class == 'JD_PhoneUiShortcutData':
shortcuts = set()
for item in message.get('shortcuts', []):
if item['__class'] == 'JD_PhoneAction_Shortcut':
try:
shortcuts.add(Command(item['shortcutType']))
except Exception as e:
print('Unknown Command: ', e)
self.available_shortcuts = shortcuts
elif __class == 'JD_OpenPhoneKeyboard_ConsoleCommandData':
await asyncio.sleep(1)
await self.send_message('JD_CancelKeyboard_PhoneCommandData')
elif __class == 'JD_PhoneUiSetupData':
self.is_input_allowed = True
shortcuts = set()
if message.get('setupData', {}).get('gameplaySetup', {}).get('pauseSlider', {}):
self.available_shortcuts.add(Command.PAUSE)
if message['isPopup'] == 1:
self.is_input_allowed = True
else:
self.is_input_allowed = (message.get('inputSetup', {}).get('isEnabled', 0) == 1)
async def send_hello(self):
print('Pairing...')
await self.send_message('JD_PhoneDataCmdHandshakeHello', {
'accelAcquisitionFreqHz': float(self.accel_acquisition_freq_hz),
'accelAcquisitionLatency': float(self.accel_acquisition_latency),
'accelMaxRange': float(self.accel_max_range),
})
async for message in self.ws:
await self.on_message(message)
async def send_accelerometer_data(self):
accel_data = []
delta_time = 0
end = time.time()
while True:
if self.disconnected:
break
if not self.should_start_accelerometer:
await asyncio.sleep(0.5)
continue
start = time.time()
if delta_time > ACCEL_SEND_RATE:
delta_time = 0
while len(accel_data) > 0:
accels_num = min(len(accel_data), 10)
await self.send_message('JD_PhoneScoringData', {
'accelData': accel_data[:accels_num],
'timeStamp': self.number_of_accels_sent,
})
self.number_of_accels_sent += accels_num
accel_data = accel_data[accels_num:]
try:
await asyncio.sleep(JOYCON_UPDATE_RATE)
joycon_status = self.joycon.get_status()
except OSError:
self.disconnect()
return
# Accelerator axes on phone & Joy-Con are different so we need to swap some axes
# https://github.com/dekuNukem/Nintendo_Switch_Reverse_Engineering/blob/master/imu_sensor_notes.md
accel = joycon_status['accel']
x = accel['y'] * -1
y = accel['x']
z = accel['z']
accel_data.append([x, y, z])
end = time.time()
delta_time += (end - start) * 1000
async def send_command(self):
''' Capture Joycon's input and send to console '''
while True:
try:
if self.disconnected:
return
if not self.is_input_allowed and not self.should_start_accelerometer:
await asyncio.sleep(JOYCON_UPDATE_RATE * 5)
continue
await asyncio.sleep(JOYCON_UPDATE_RATE * 5)
cmd = None
# Get pressed button
for event_type, status in self.joycon.events():
if status == 0: # 0 = pressed, 1 = released
continue
joycon_button = JoyConButton(event_type)
if self.should_start_accelerometer: # Can only send Pause command while playing
if joycon_button == JoyConButton.PLUS or joycon_button == JoyConButton.MINUS:
cmd = Command.PAUSE
else:
if joycon_button == JoyConButton.A or joycon_button == JoyConButton.RIGHT:
cmd = Command.ACCEPT
elif joycon_button == JoyConButton.B or joycon_button == JoyConButton.DOWN:
cmd = Command.BACK
elif joycon_button in SHORTCUT_MAPPING:
# Get command depends on which button was pressed & which shortcuts are available
for shortcut in SHORTCUT_MAPPING[joycon_button]:
if shortcut in self.available_shortcuts:
cmd = shortcut
break
# Get joystick direction
if not self.should_start_accelerometer and not cmd:
status = self.joycon.get_status()
# Check which Joycon (L/R) is being used
stick = status['analog-sticks']['left'] if self.joycon_is_left else status['analog-sticks']['right']
vertical = stick['vertical']
horizontal = stick['horizontal']
if vertical < -0.5:
cmd = Command.DOWN
elif vertical > 0.5:
cmd = Command.UP
elif horizontal < -0.5:
cmd = Command.LEFT
elif horizontal > 0.5:
cmd = Command.RIGHT
# Send command to server
if cmd:
if cmd == Command.PAUSE:
__class = 'JD_Pause_PhoneCommandData'
data = {}
elif type(cmd.value) == str:
__class = 'JD_Custom_PhoneCommandData'
data = {
'identifier': cmd.value,
}
else:
__class = 'JD_Input_PhoneCommandData'
data = {
'input': cmd.value,
}
# Only send input when it's allowed to, otherwise we might get a disconnection
if self.is_input_allowed:
await self.send_message(__class, data)
await asyncio.sleep(0.01)
except Exception as e:
print(e)
await self.disconnect()
async def connect_ws(self):
ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ssl_context.set_ciphers('ALL')
ssl_context.options &= ~ssl.OP_NO_SSLv3
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
server_hostname = self.console_conn.getpeername()[0] if self.console_conn else None
try:
async with websockets.connect(
self.pairing_url,
subprotocols=[WS_SUBPROTOCOL],
sock=self.console_conn,
ssl=ssl_context,
ping_timeout=None,
server_hostname=server_hostname
) as websocket:
try:
self.ws = websocket
await asyncio.gather(
self.send_hello(),
self.send_accelerometer_data(),
self.send_command(),
)
except websockets.ConnectionClosed:
await self.on_state_changed(self.joycon.serial, PairingState.ERROR_CONSOLE_CONNECTION)
await self.disconnect(close_ws=False)
except Exception:
await self.on_state_changed(self.joycon.serial, PairingState.ERROR_CONSOLE_CONNECTION)
await self.disconnect(close_ws=False)
async def disconnect(self, close_ws=True):
print('disconnected')
self.disconnected = True
self.joycon.__del__()
if close_ws and self.ws:
await self.ws.close()
async def pair(self):
try:
if self.console_ip_addr:
await self.on_state_changed(self.joycon.serial, PairingState.CONNECTING)
self.pairing_url = 'wss://{}:8080/smartphone'.format(self.console_ip_addr)
else:
await self.on_state_changed(self.joycon.serial, PairingState.GETTING_TOKEN)
print('Getting authorication token...')
await self.get_access_token()
await self.on_state_changed(self.joycon.serial, PairingState.PAIRING)
print('Sending pairing code...')
await self.send_pairing_code()
await self.on_state_changed(self.joycon.serial, PairingState.CONNECTING)
print('Connecting with console...')
if self.requires_punch_pairing:
await self.send_initiate_punch_pairing()
await self.hole_punching()
await self.connect_ws()
except Exception as e:
await self.disconnect()
print(e)

128
joydance/constants.py Normal file
View File

@ -0,0 +1,128 @@
from enum import Enum
JOYDANCE_VERSION = '0.1'
UBI_APP_ID = '210da0fb-d6a5-4ed1-9808-01e86f0de7fb'
UBI_SKU_ID = 'jdcompanion-android'
WS_SUBPROTOCOL = 'v2.phonescoring.jd.ubisoft.com'
JOYCON_UPDATE_RATE = 0.02 # 50Hz
ACCEL_SEND_RATE = 40 # ms
ACCEL_ACQUISITION_FREQ_HZ = 50 # Hz
ACCEL_ACQUISITION_LATENCY = 20 # ms
ACCEL_MAX_RANGE = 8 # ±G
DEFAULT_CONFIG = {
'pairing_method': 'default',
'host_ip_addr': '',
'console_ip_addr': '',
'pairing_code': '',
'accel_acquisition_freq_hz': ACCEL_ACQUISITION_FREQ_HZ,
'accel_acquisition_latency': ACCEL_ACQUISITION_LATENCY,
'accel_max_range': ACCEL_MAX_RANGE,
}
class Command(Enum):
UP = 3690595578
RIGHT = 1099935642
DOWN = 2467711647
LEFT = 3652315484
ACCEPT = 1084313942
PAUSE = 'PAUSE'
BACK = 'SHORTCUT_BACK'
CHANGE_DANCERCARD = 'SHORTCUT_CHANGE_DANCERCARD'
FAVORITE = 'SHORTCUT_FAVORITE'
GOTO_SONGSTAB = 'SHORTCUT_GOTO_SONGSTAB'
SKIP = 'SHORTCUT_SKIP'
SORTING = 'SHORTCUT_SORTING'
SWAP_GENDER = 'SHORTCUT_SWAP_GENDER'
SWEAT_ACTIVATION = 'SHORTCUT_SWEAT_ACTIVATION'
TOGGLE_COOP = 'SHORTCUT_TOGGLE_COOP'
UPLAY = 'SHORTCUT_UPLAY'
ACTIVATE_DANCERCARD = 'SHORTCUT_ACTIVATE_DANCERCARD'
DELETE_DANCERCARD = 'SHORTCUT_DELETE_DANCERCARD'
DELETE_PLAYLIST = 'SHORTCUT_DELETE_PLAYLIST'
SAVE_PLAYLIST = 'SHORTCUT_SAVE_PLAYLIST'
PLAYLIST_RENAME = 'SHORTCUT_PLAYLIST_RENAME'
PLAYLIST_DELETE_SONG = 'SHORTCUT_PLAYLIST_DELETE_SONG'
PLAYLIST_MOVE_SONG_LEFT = 'SHORTCUT_PLAYLIST_MOVE_SONG_LEFT'
PLAYLIST_MOVE_SONG_RIGHT = 'SHORTCUT_PLAYLIST_MOVE_SONG_RIGHT'
TIPS_NEXT = 'SHORTCUT_TIPS_NEXT'
TIPS_PREVIOUS = 'SHORTCUT_TIPS_PREVIOUS'
class JoyConButton(Enum):
# Joy-Con (L)
UP = 'up'
RIGHT = 'right'
DOWN = 'down'
LEFT = 'left'
L = 'l'
ZL = 'zl'
MINUS = 'minus'
CAPTURE = 'capture'
LEFT_STICK = 'stick_l_btn'
LEFT_SR = 'left_sr'
LEFT_SL = 'left_sl'
# Joy-Con (R)
A = 'a'
B = 'b'
X = 'x'
Y = 'y'
R = 'r'
ZR = 'zr'
PLUS = 'plus'
HOME = 'home'
RIGHT_STICK = 'stick_r_btn'
RIGHT_SL = 'right_sl'
RIGHT_SR = 'right_sr'
CHARGING_GRIP = 'charging-grip'
# Assign buttons on Joy-Con (R) with commands
SHORTCUT_MAPPING = {
JoyConButton.X: [
Command.DELETE_DANCERCARD,
Command.DELETE_PLAYLIST,
Command.GOTO_SONGSTAB,
Command.PLAYLIST_DELETE_SONG,
Command.SKIP,
Command.SORTING,
Command.SWAP_GENDER,
Command.TOGGLE_COOP,
],
JoyConButton.Y: [
Command.ACTIVATE_DANCERCARD,
Command.CHANGE_DANCERCARD,
Command.SWEAT_ACTIVATION,
Command.UPLAY,
],
JoyConButton.PLUS: [
Command.FAVORITE,
Command.PAUSE,
Command.PLAYLIST_RENAME,
Command.SAVE_PLAYLIST,
],
JoyConButton.R: [
Command.PLAYLIST_MOVE_SONG_LEFT,
Command.TIPS_PREVIOUS,
],
JoyConButton.ZR: [
Command.PLAYLIST_MOVE_SONG_RIGHT,
Command.TIPS_NEXT,
],
}
# Same with Joy-Con (L)
SHORTCUT_MAPPING[JoyConButton.UP] = JoyConButton.X
SHORTCUT_MAPPING[JoyConButton.LEFT] = JoyConButton.Y
SHORTCUT_MAPPING[JoyConButton.MINUS] = JoyConButton.PLUS
SHORTCUT_MAPPING[JoyConButton.L] = JoyConButton.R
SHORTCUT_MAPPING[JoyConButton.ZL] = JoyConButton.ZR

5
requirements.txt Normal file
View File

@ -0,0 +1,5 @@
https://github.com/redphx/joycon-python/archive/refs/heads/master.zip#egg=joycon-python
websockets==10.2
aiohttp==3.8.1
hidapi==0.11.2
pyglm==2.5.7

180
static/css/app.css Normal file
View File

@ -0,0 +1,180 @@
body {
max-width: 500px;
margin: 10px auto;
background-color: #1c1b1f;
color: #e1e1e1;
}
.container {
border-radius: 10px;
background-color: #272629;
padding: 20px;
margin: 10px;
}
.ascii {
text-align: center;
padding: 8px 0;
}
.ascii pre {
font-family: ui-monospace, 'Segoe UI Mono', 'Roboto Mono', 'Courier New', monospace;
display: inline-block;
text-align: left;
margin: 0;
font-weight: bold;
font-size: 10px;
line-height: 10px;
color: #FFF;
}
.flex {
display: flex;
}
.joycons {
margin-top: 10px;
}
.joycons-wrapper button {
width: 100%;
}
.joycons .joycons-wrapper {
position: relative;
}
.joycons .joycons-wrapper .empty {
height: 60px;
line-height: 60px;
margin: auto;
text-align: center;
}
.joycons .joycons-wrapper .joycons-list {
padding: 0;
margin: 0;
list-style: none;
}
.joycons .joycons-wrapper .joycons-list li {
border-bottom: 1px solid #2f2f2f;
padding: 12px 0;
}
.joycons .joycons-wrapper .joycons-list .joycon-color {
width: 35%;
margin: auto;
}
.joycons .joycons-wrapper .joycons-list .joycon-name {
align-items: center;
font-size: 16px;
font-weight: bold;
}
.joycons .joycons-wrapper .joycons-list .joycon-info {
display: flex;
flex-direction: column;
}
.joycons .joycons-wrapper .joycons-list .joycon-info .joycon-state {
color: #757575;
font-size: 14px;
text-align: left;
}
.joycons-list .pairing-code {
border-radius: 4px;
font-size: 12px;
background-color: #202020;
display: inline-block;
color: #a6a6a6;
padding: 4px;
margin: auto;
}
.joycons .btn-refresh {
color: #383000;
background-color: #e2c700;
margin: auto;
}
input, select {
box-shadow: none !important;
border-width: 2px !important;
}
input:invalid {
border-color: #e9322d !important;
}
input[readonly] {
background-color: #ccc !important;
color: #000;
}
#ipAddr, #pairingCode, .joycons-list .pairing-code {
font-family: ui-monospace, 'Segoe UI Mono', 'Roboto Mono', 'Courier New', monospace;
}
.footer {
text-align: center;
margin-top: 16px;
}
.footer a {
text-decoration: none;
color: #424242;
font-size: 14px;
}
.footer a:hover {
color: #4d4d4d;
}
.pure-button-primary {
background-color: #1976D2 !important;
color: #E3F2FD !important;
}
.pure-form fieldset {
padding-bottom: 0;
}
.battery-level {
display: inline-block;
margin-left: 8px;
}
.battery-level svg {
width: 16px;
margin: auto;
vertical-align: middle;
}
.battery-level.full > * {
fill: #8BC34A;
}
.battery-level.medium > * {
fill: #FFEB3B;
}
.battery-level.low > * {
fill: #FF9800;
}
.battery-level.critical > * {
fill: #FF5722;
}
.battery-level.medium .battery-bar-4,
.battery-level.low .battery-bar-3,
.battery-level.low .battery-bar-4,
.battery-level.critical .battery-bar-2,
.battery-level.critical .battery-bar-3,
.battery-level.critical .battery-bar-4 {
display: none;
}

7
static/css/grids-responsive-min.css vendored Normal file

File diff suppressed because one or more lines are too long

11
static/css/pure-min.css vendored Normal file

File diff suppressed because one or more lines are too long

BIN
static/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 B

17
static/index.html Normal file
View File

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<title>JoyDance</title>
<meta charset="utf-8">
<link rel="icon" type="image/png" href="/favicon.png">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<link href="/css/pure-min.css" type="text/css" rel="stylesheet" />
<link href="/css/grids-responsive-min.css" type="text/css" rel="stylesheet" />
<link href="/css/app.css" type="text/css" rel="stylesheet" />
<script type="text/javascript">window.CONFIG = [[CONFIG]]; window.VERSION = '[[VERSION]]';</script>
<script type="text/javascript" src="/js/mitt.umd.js"></script>
<script type="module" src="/js/app.js"></script>
</head>
<body>
</body>
</html>

507
static/js/app.js Normal file
View File

@ -0,0 +1,507 @@
import { h, Component, render } from '/js/preact.module.js';
import htm from '/js/htm.module.js';
// Initialize htm with Preact
const html = htm.bind(h);
window.mitty = mitt()
const SVG_BATTERY_LEVEL = html`<svg style="enable-background:new 0 0 16 16" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="M15 4H0v8h15V9h1V7h-1V4zm-1 3v4H1V5h13v2z"/><rect class="battery-bar-4" height="4" width="2" x="11" y="6"/><rect class="battery-bar-3" height="4" width="2" x="8" y="6"/><rect class="battery-bar-2" height="4" width="2" x="5" y="6"/><rect class="battery-bar-1" height="4" width="2" x="2" y="6"/></svg>`
const BATTERY_LEVEL = {
4: 'full',
3: 'medium',
2: 'low',
1: 'critical',
0: 'critical',
}
const PairingMethod = {
DEFAULT: 'default',
FAST: 'fast'
}
const WsCommand = {
GET_JOYCON_LIST: 'get_joycon_list',
CONNECT_JOYCON: 'connect_joycon',
DISCONNECT_JOYCON: 'disconnect_joycon',
UPDATE_JOYCON_STATE: 'update_joycon_state',
}
const PairingState = {
IDLE: 0,
GETTING_TOKEN: 1,
PAIRING: 2,
CONNECTING: 3,
CONNECTED: 4,
DISCONNECTING: 5,
DISCONNECTED: 10,
ERROR_JOYCON: 101,
ERROR_CONNECTION: 102,
ERROR_INVALID_PAIRING_CODE: 103,
ERROR_PUNCH_PAIRING: 104,
ERROR_HOLE_PUNCHING: 105,
ERROR_CONSOLE_CONNECTION: 106,
}
const PairingStateMessage = {
[PairingState.IDLE]: 'Idle',
[PairingState.GETTING_TOKEN]: 'Getting auth token...',
[PairingState.PAIRING]: 'Sending pairing code...',
[PairingState.CONNECTING]: 'Connecting with console...',
[PairingState.CONNECTED]: 'Connected!',
[PairingState.DISCONNECTED]: 'Disconnected',
[PairingState.ERROR_JOYCON]: 'Joy-Con problem!',
[PairingState.ERROR_CONNECTION]: 'Couldn\'t get auth token!',
[PairingState.ERROR_INVALID_PAIRING_CODE]: 'Invalid pairing code!',
[PairingState.ERROR_PUNCH_PAIRING]: 'Couldn\'t punch pairing!',
[PairingState.ERROR_HOLE_PUNCHING]: 'Couldn\'t connect with console!',
[PairingState.ERROR_CONSOLE_CONNECTION]: 'Couldn\'t connect with console!',
}
class PairingMethodPicker extends Component {
constructor(props) {
super()
this.state = {
pairing_method: props.pairing_method,
}
this.onChange = this.onChange.bind(this)
}
onChange(e) {
const pairing_method = e.target.value
this.setState({
pairing_method: pairing_method,
})
window.mitty.emit('update_method', pairing_method)
}
render(props) {
return html`
<label for="stacked-state">Pairing Method</label>
<select id="stacked-state" onChange=${this.onChange} value=${props.pairing_method}>
<option value="${PairingMethod.DEFAULT}">Default: All platforms (incl. Xbox Series/Stadia)</option>
<option value="${PairingMethod.FAST}">Fast: Xbox One/PlayStation/Nintendo Switch</option>
</select>
`
}
}
class PrivateIpAddress extends Component {
constructor(props) {
super(props)
let lock_host = false
let host_ip_addr = props.host_ip_addr
let console_ip_addr = props.console_ip_addr
let hostname = window.location.hostname
if (hostname.startsWith('192.168.')) {
host_ip_addr = hostname
lock_host = true
}
this.state = {
host_ip_addr: host_ip_addr,
console_ip_addr: console_ip_addr,
lock_host: lock_host,
}
this.onKeyPress = this.onKeyPress.bind(this)
this.onChange = this.onChange.bind(this)
}
onChange(e) {
const key = this.props.pairing_method == PairingMethod.DEFAULT ? 'host_ip_addr' : 'console_ip_addr'
const value = e.target.value
this.setState({
[key]: value,
})
window.mitty.emit('update_addr', value)
}
onKeyPress(e) {
if (!/[0-9\.]/.test(e.key)) {
e.preventDefault()
return
}
}
componentDidMount() {
window.mitty.emit('update_addr', this.state.host_ip_addr)
}
render(props, state) {
const pairing_method = props.pairing_method
const addr = pairing_method == PairingMethod.DEFAULT ? state.host_ip_addr : state.console_ip_addr
return html`
<label>
${pairing_method == PairingMethod.DEFAULT && html`Host's Private IP Address`}
${pairing_method == PairingMethod.FAST && html`Console's Private IP Address`}
</label>
${pairing_method == PairingMethod.DEFAULT && state.lock_host && html`
<input readonly required id="ipAddr" type="text" size="15" placeholder="${addr}" />
`}
${(pairing_method == PairingMethod.FAST || !state.lock_host) && html`
<input required id="ipAddr" type="text" inputmode="decimal" size="15" maxlength="15" placeholder="192.168.x.x" pattern="^192\\.168\\.((\\d{1,2}|1\\d\\d|2[0-4]\\d|25[0-5])\\.)(\\d{1,2}|1\\d\\d|2[0-4]\\d|25[0-5])$" value=${addr} onKeyPress=${this.onKeyPress} onChange="${this.onChange}" />
`}
`
}
}
class PairingCode extends Component {
constructor(props) {
super(props)
this.state = {
pairing_code: props.pairing_code,
}
this.onChange = this.onChange.bind(this)
}
onChange(e) {
const value = e.target.value
this.setState({
pairing_code: value,
})
window.mitty.emit('update_code', value)
}
render(props, state) {
return html`
<label>Pairing Code</label>
${props.pairing_method == PairingMethod.DEFAULT && html`
<input required id="pairingCode" type="text" inputmode="decimal" value=${state.pairing_code} placeholder="000000" maxlength="6" size="6" pattern="[0-9]{6}" onKeyPress=${(e) => !/[0-9]/.test(e.key) && e.preventDefault()} onChange=${this.onChange} />
`}
${props.pairing_method == PairingMethod.FAST && html`
<input type="text" id="pairingCode" value="" readonly placeholder="Not Required" size="12" />
`}
`
}
}
class JoyCon extends Component {
constructor(props) {
super(props)
this.connect = this.connect.bind(this)
this.disconnect = this.disconnect.bind(this)
this.onStateUpdated = this.onStateUpdated.bind(this)
this.state = {
...props.joycon,
}
window.mitty.on('resp_' + WsCommand.DISCONNECT_JOYCON, this.onDisconnected)
window.mitty.on('resp_' + WsCommand.UPDATE_JOYCON_STATE, this.onStateUpdated)
}
connect() {
window.mitty.emit('req_' + WsCommand.CONNECT_JOYCON, this.props.joycon.serial)
}
disconnect() {
window.mitty.emit('req_' + WsCommand.DISCONNECT_JOYCON, this.props.joycon.serial)
}
onStateUpdated(data) {
if (data['serial'] != this.props.joycon.serial) {
return
}
const state = data['state']
if (PairingStateMessage.hasOwnProperty(state)) {
this.setState({
...data,
})
}
}
render(props, { name, state, pairing_code, is_left, color, battery_level }) {
const joyconState = state
const stateMessage = PairingStateMessage[joyconState]
let showButton = true
if ([PairingState.GETTING_TOKEN, PairingState.PAIRING, PairingState.CONNECTING].indexOf(joyconState) > -1) {
showButton = false
}
let joyconSvg
if (is_left) {
joyconSvg = html`<svg class="joycon-color" viewBox="0 0 171 453" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd"><path d="M219.594 33.518v412.688c0 1.023-.506 1.797-1.797 1.797h-49.64c-51.68 0-85.075-45.698-85.075-85.075V114.987c0-57.885 56.764-84.719 84.719-84.719h48.79c2.486 0 3.003 1.368 3.003 3.25zm-32.123 105.087c0 17.589-14.474 32.062-32.063 32.062-17.589 0-32.062-14.473-32.062-32.062s14.473-32.063 32.062-32.063 32.063 14.474 32.063 32.063z" style="fill:${color};stroke:#000;stroke-width:8.33px" transform="translate(-65.902 -13.089)"/></svg>`
} else {
joyconSvg = html`<svg class="joycon-color" viewBox="0 0 171 453" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd"><path d="M324.763 40.363v412.688c0 1.023.506 1.797 1.797 1.797h49.64c51.68 0 85.075-45.698 85.075-85.075V121.832c0-6.774-.777-13.123-2.195-19.054-10.696-44.744-57.841-65.665-82.524-65.665h-48.79c-2.486 0-3.003 1.368-3.003 3.25zm96 218.094c0 17.589-14.473 32.063-32.062 32.063s-32.063-14.474-32.063-32.063c0-17.589 14.474-32.062 32.063-32.062 17.589 0 32.062 14.473 32.062 32.062z" style="fill:${color};fill-rule:nonzero;stroke:#000;stroke-width:8.33px" transform="translate(-307.583 -19.934)"/></svg>`
}
const batteryLevel = BATTERY_LEVEL[battery_level]
return html`
<li>
<div class="pure-g">
<div class="pure-u-2-24 flex">${joyconSvg}</div>
<div class="pure-u-12-24 joycon-info">
<div class="flex">
<span class="joycon-name">${name}</span>
<span class="battery-level ${batteryLevel}">${SVG_BATTERY_LEVEL}</span>
</div>
<span class="joycon-state">${stateMessage}</span>
</div>
<div class="pure-u-4-24 flex">
${pairing_code && html`
<span class="pairing-code">${pairing_code}</span>
`}
</div>
<div class="pure-u-6-24">
${showButton && joyconState == PairingState.CONNECTED && html`
<button type="button" onClick=${this.disconnect} class="pure-button pure-button-error">Disconnect</button>
`}
${showButton && joyconState != PairingState.CONNECTED && html`
<button type="button" onClick=${this.connect} class="pure-button pure-button-primary">Connect</button>
`}
</div>
</div>
</li>
`
}
}
class JoyCons extends Component {
constructor() {
super()
this.state = {
isRefreshing: false,
}
this.refreshJoyconList = this.refreshJoyconList.bind(this)
}
refreshJoyconList() {
this.setState({
isRefreshing: false,
})
window.mitty.emit('req_' + WsCommand.GET_JOYCON_LIST)
}
componentDidMount() {
}
render(props, state) {
return html`
<div class="pure-g">
<h2 class="pure-u-18-24">Joy-Cons</h2>
${state.isRefreshing && html`
<button type="button" disabled class="pure-button btn-refresh pure-u-6-24">Refresh</a>
`}
${!state.isRefreshing && html`
<button type="button" class="pure-button btn-refresh pure-u-6-24" onClick=${this.refreshJoyconList}>Refresh</button>
`}
</div>
<div class="joycons-wrapper">
${props.joycons.length == 0 && html`
<p class="empty">No Joy-Cons found!</p>
`}
${props.joycons.length > 0 && html`
<ul class="joycons-list">
${props.joycons.map(item => (
html`<${JoyCon} joycon=${item} key=${item.serial} />`
))}
</ul>
`}
</div>
`
}
}
class App extends Component {
constructor(props) {
super()
this.state = {
pairing_method: window.CONFIG.pairing_method,
host_ip_addr: window.CONFIG.host_ip_addr,
console_ip_addr: window.CONFIG.console_ip_addr,
pairing_code: window.CONFIG.pairing_code,
joycons: [],
}
this.connectWs = this.connectWs.bind(this)
this.sendRequest = this.sendRequest.bind(this)
this.requestGetJoyconList = this.requestGetJoyconList.bind(this)
this.requestConnectJoycon = this.requestConnectJoycon.bind(this)
this.requestDisconnectJoycon = this.requestDisconnectJoycon.bind(this)
this.handleMethodChange = this.handleMethodChange.bind(this)
this.handleAddrChange = this.handleAddrChange.bind(this)
this.handleCodeChange = this.handleCodeChange.bind(this)
window.mitty.on('req_' + WsCommand.GET_JOYCON_LIST, this.requestGetJoyconList)
window.mitty.on('req_' + WsCommand.CONNECT_JOYCON, this.requestConnectJoycon)
window.mitty.on('req_' + WsCommand.DISCONNECT_JOYCON, this.requestDisconnectJoycon)
window.mitty.on('update_method', this.handleMethodChange)
window.mitty.on('update_addr', this.handleAddrChange)
window.mitty.on('update_code', this.handleCodeChange)
}
sendRequest(cmd, data) {
if (!data) {
data = {}
}
const msg = {
cmd: cmd,
data: data,
}
console.log('send', msg)
this.socket.send(JSON.stringify(msg))
}
requestGetJoyconList() {
this.sendRequest(WsCommand.GET_JOYCON_LIST);
}
requestConnectJoycon(serial) {
const state = this.state
const pairing_method = state.pairing_method
let addr = pairing_method == PairingMethod.DEFAULT ? state.host_ip_addr : state.console_ip_addr
if (!addr.match(/^192\.168\.((\d{1,2}|1\d\d|2[0-4]\d|25[0-5])\.)(\d{1,2}|1\d\d|2[0-4]\d|25[0-5])$/)) {
alert('ERROR: Invalid IP address!')
document.getElementById('ipAddr').focus()
return
}
if (pairing_method == PairingMethod.DEFAULT) {
const pairing_code = state.pairing_code
if (!pairing_code.match(/^\d{6}$/)) {
alert('ERROR: Invalid pairing code!')
document.getElementById('pairingCode').focus()
return
}
}
this.sendRequest(WsCommand.CONNECT_JOYCON, {
pairing_method: state.pairing_method,
host_ip_addr: state.host_ip_addr,
console_ip_addr: state.console_ip_addr,
pairing_code: state.pairing_code,
joycon_serial: serial,
})
}
requestDisconnectJoycon(serial) {
this.sendRequest(WsCommand.DISCONNECT_JOYCON, {
joycon_serial: serial,
})
}
connectWs() {
const that = this
this.socket = new WebSocket('ws://' + window.location.host + '/ws')
this.socket.onopen = function(e) {
console.log('[open] Connection established')
that.requestGetJoyconList()
}
this.socket.onmessage = function(event) {
const msg = JSON.parse(event.data)
console.log(msg)
const cmd = msg['cmd']
const shortCmd = msg['cmd'].slice(5) // Remove "resp_" prefix
switch (shortCmd) {
case WsCommand.GET_JOYCON_LIST:
that.setState({
joycons: msg['data'],
})
break
default:
window.mitty.emit(cmd, msg['data'])
}
}
this.socket.onclose = function(event) {
if (event.wasClean) {
console.log(`[close] Connection closed cleanly, code=${event.code} reason=${event.reason}`);
} else {
console.log('[close] Connection died');
}
}
this.socket.onerror = function(error) {
console.log(`[error] ${error.message}`);
}
}
handleMethodChange(pairing_method) {
this.setState({
pairing_method: pairing_method,
})
}
handleAddrChange(addr) {
const key = this.state.pairing_method == PairingMethod.DEFAULT ? 'host_ip_addr' : 'console_ip_addr'
this.setState({
[key]: addr,
})
}
handleCodeChange(pairing_code) {
this.setState({
pairing_code: pairing_code,
})
}
componentDidMount() {
this.connectWs()
}
render(props, state) {
return html`
<div class="container">
<div class="ascii">
<pre>
</pre>
</div>
<form class="pure-form pure-form-stacked">
<fieldset>
<div class="pure-g">
<h2 class="pure-u-1">Config</h2>
<div class="pure-u-1">
<${PairingMethodPicker} pairing_method=${state.pairing_method}/>
</div>
<div class="pure-u-1-2">
<${PrivateIpAddress} pairing_method=${state.pairing_method} host_ip_addr=${state.host_ip_addr} console_ip_addr=${state.console_ip_addr} />
</div>
<div class="pure-u-1-2">
<${PairingCode} pairing_method=${state.pairing_method} pairing_code=${state.pairing_code} />
</div>
</div>
</fieldset>
</form>
<div class="pure-u-1 joycons">
<${JoyCons} pairing_method=${state.pairing_method} joycons=${state.joycons} />
</div>
</div>
<div class="footer">
<a href="https://github.com/redphx/joydance" target="_blank">${window.VERSION}</a>
</div>
`
}
}
render(html`<${App} />`, document.body)

1
static/js/htm.module.js Normal file
View File

@ -0,0 +1 @@
var n = function (t, s, r, e) {var u;s[0] = 0;for (var h = 1; h < s.length; h++) {var p = s[h++],a = s[h] ? (s[0] |= p ? 1 : 2, r[s[h++]]) : s[++h];3 === p ? e[0] = a : 4 === p ? e[1] = Object.assign(e[1] || {}, a) : 5 === p ? (e[1] = e[1] || {})[s[++h]] = a : 6 === p ? e[1][s[++h]] += a + "" : p ? (u = t.apply(a, n(t, a, r, ["", null])), e.push(u), a[0] ? s[0] |= 2 : (s[h - 2] = 0, s[h] = u)) : e.push(a);}return e;},t = new Map();export default function (s) {var r = t.get(this);return r || (r = new Map(), t.set(this, r)), (r = n(this, r.get(s) || (r.set(s, r = function (n) {for (var t, s, r = 1, e = "", u = "", h = [0], p = function (n) {1 === r && (n || (e = e.replace(/^\s*\n\s*|\s*\n\s*$/g, ""))) ? h.push(0, n, e) : 3 === r && (n || e) ? (h.push(3, n, e), r = 2) : 2 === r && "..." === e && n ? h.push(4, n, 0) : 2 === r && e && !n ? h.push(5, 0, !0, e) : r >= 5 && ((e || !n && 5 === r) && (h.push(r, 0, e, s), r = 6), n && (h.push(r, n, 0, s), r = 6)), e = "";}, a = 0; a < n.length; a++) {a && (1 === r && p(), p(a));for (var l = 0; l < n[a].length; l++) t = n[a][l], 1 === r ? "<" === t ? (p(), h = [h], r = 3) : e += t : 4 === r ? "--" === e && ">" === t ? (r = 1, e = "") : e = t + e[0] : u ? t === u ? u = "" : e += t : '"' === t || "'" === t ? u = t : ">" === t ? (p(), r = 1) : r && ("=" === t ? (r = 5, s = e, e = "") : "/" === t && (r < 5 || ">" === n[a][l + 1]) ? (p(), 3 === r && (h = h[0]), r = h, (h = h[0]).push(2, 0, r), r = 0) : " " === t || "\t" === t || "\n" === t || "\r" === t ? (p(), r = 2) : e += t), 3 === r && "!--" === e && (r = 4, h = h[0]);}return p(), h;}(s)), r), arguments, [])).length > 1 ? r : r[0];}

1
static/js/mitt.umd.js Normal file
View File

@ -0,0 +1 @@
!function(e,n){"object"==typeof exports&&"undefined"!=typeof module?module.exports=n():"function"==typeof define&&define.amd?define(n):(e=e||self).mitt=n()}(this,function(){return function(e){return{all:e=e||new Map,on:function(n,t){var f=e.get(n);f?f.push(t):e.set(n,[t])},off:function(n,t){var f=e.get(n);f&&(t?f.splice(f.indexOf(t)>>>0,1):e.set(n,[]))},emit:function(n,t){var f=e.get(n);f&&f.slice().map(function(e){e(t)}),(f=e.get("*"))&&f.slice().map(function(e){e(n,t)})}}}});

File diff suppressed because one or more lines are too long