|
|
@@ -1,52 +1,89 @@ |
|
|
|
const State = { |
|
|
|
websocket: null, |
|
|
|
online: [], |
|
|
|
posts: [], |
|
|
|
users: [], |
|
|
|
media: null, |
|
|
|
username: null, |
|
|
|
} |
|
|
|
|
|
|
|
/* |
|
|
|
* |
|
|
|
* WEBRTC |
|
|
|
* SIGNALING |
|
|
|
* |
|
|
|
*/ |
|
|
|
const rpcCall = async (username) => { |
|
|
|
const constraints = {audio: true, video: true} |
|
|
|
const media = await navigator.mediaDevices.getUserMedia(constraints) |
|
|
|
const rpc = new RTCPeerConnection({iceServers: []}) |
|
|
|
// rpc.oniceconnectionstatechange = (e) => { |
|
|
|
// console.log(e) |
|
|
|
// } |
|
|
|
// rpc.onicecandidate = (e) => { |
|
|
|
// e.candidate |
|
|
|
// const candidate = new RTCIceCandidate(); |
|
|
|
// rpc.addIceCandidate(candidate); |
|
|
|
// } |
|
|
|
// rpc.ontrack = (e) => { |
|
|
|
// console.log(e) |
|
|
|
// // appendVideo(media) |
|
|
|
// } |
|
|
|
const wire = (message) => State.websocket.send(JSON.stringify(message)) |
|
|
|
const signal = (message) => dispatchEvent(new CustomEvent(message.kind, {detail: message})) |
|
|
|
const listen = (kind, handler) => addEventListener(kind, handler) |
|
|
|
listen('login', ({detail}) => State.username = detail.value) |
|
|
|
listen('post', ({detail}) => State.posts.push(detail)) |
|
|
|
listen('peerInfo', (e) => onPeerInfo(e)) |
|
|
|
const doNotLog = new Set(['login', 'post', 'peerInfo']) |
|
|
|
|
|
|
|
const makeOffer = async () => { |
|
|
|
rpc.addStream(media) |
|
|
|
const offer = await rpc.createOffer() |
|
|
|
await rpc.setLocalDescription(offer) |
|
|
|
return {msgType: 'offer', rsd: rpc.localDescription} |
|
|
|
/* |
|
|
|
* |
|
|
|
* WEBRTC |
|
|
|
* |
|
|
|
*/ |
|
|
|
const rpcs = {} |
|
|
|
const streams = {} |
|
|
|
const getOrCreateRpc = (username) => { |
|
|
|
if(!rpcs[username] && State.media) { |
|
|
|
const rpc = new RTCPeerConnection({iceServers: [{urls: 'stun:stun.sipgate.net:3478'}]}) |
|
|
|
State.media.getTracks().forEach(track => rpc.addTrack(track, State.media)) |
|
|
|
rpc.onicecandidate = ({candidate}) => { |
|
|
|
if(candidate) { |
|
|
|
wire({kind: 'peerInfo', value: {type: 'candidate', candidate}}) |
|
|
|
} |
|
|
|
} |
|
|
|
rpc.ontrack = (e) => { |
|
|
|
streams[username] = e.streams[0] |
|
|
|
m.redraw() |
|
|
|
} |
|
|
|
rpc.onclose = (e) => { |
|
|
|
console.log(username, e) |
|
|
|
} |
|
|
|
rpcs[username] = rpc |
|
|
|
} |
|
|
|
|
|
|
|
const makeAnswer = async (msg) => { |
|
|
|
const offer = new RTCSessionDescription(msg.rsd) |
|
|
|
await rpc.setRemoteDescription(offer) |
|
|
|
const answer = await rpc.createAnswer() |
|
|
|
await rpc.setLocalDescription(answer) |
|
|
|
return {msgType: 'answer', rsd: rpc.localDescription} |
|
|
|
return rpcs[username] |
|
|
|
} |
|
|
|
const onPeerInfo = async ({detail: message}) => { |
|
|
|
if(State.username === message.source) { |
|
|
|
return |
|
|
|
} |
|
|
|
|
|
|
|
const finishShake = async (msg) => { |
|
|
|
const answer = new RTCSessionDescription(msg.rsd) |
|
|
|
await rpc.setRemoteDescription(answer) |
|
|
|
|
|
|
|
const rpc = getOrCreateRpc(message.source) |
|
|
|
if(rpc && message.value.type === 'request') { |
|
|
|
const localOffer = await rpc.createOffer() |
|
|
|
await rpc.setLocalDescription(localOffer) |
|
|
|
wire({kind: 'peerInfo', value: localOffer}) |
|
|
|
} |
|
|
|
else if(rpc && message.value.type === 'offer') { |
|
|
|
const remoteOffer = new RTCSessionDescription(message.value) |
|
|
|
await rpc.setRemoteDescription(remoteOffer) |
|
|
|
const localAnswer = await rpc.createAnswer() |
|
|
|
await rpc.setLocalDescription(localAnswer) |
|
|
|
wire({kind: 'peerInfo', value: localAnswer}) |
|
|
|
} |
|
|
|
else if(rpc && message.value.type === 'answer') { |
|
|
|
const remoteAnswer = new RTCSessionDescription(message.value) |
|
|
|
await rpc.setRemoteDescription(remoteAnswer) |
|
|
|
} |
|
|
|
else if(rpc && message.value.type === 'candidate') { |
|
|
|
const candidate = new RTCIceCandidate(message.value.candidate) |
|
|
|
rpc.addIceCandidate(candidate) |
|
|
|
} |
|
|
|
else if(message.value.type === 'stop') { |
|
|
|
if(streams[message.source]) { |
|
|
|
streams[message.source].getTracks().map(track => track.stop()) |
|
|
|
delete streams[message.source] |
|
|
|
} |
|
|
|
if(rpcs[message.source]) { |
|
|
|
rpcs[message.source].close() |
|
|
|
delete rpcs[message.source] |
|
|
|
} |
|
|
|
} |
|
|
|
else { |
|
|
|
console.log('uncaught', message) |
|
|
|
} |
|
|
|
finishShake(await makeAnswer(await makeOffer())) |
|
|
|
} |
|
|
|
|
|
|
|
/* |
|
|
@@ -63,6 +100,52 @@ const scrollIntoView = (vnode) => { |
|
|
|
const prettyTime = (ts) => { |
|
|
|
return ts.slice(11, 19) |
|
|
|
} |
|
|
|
const Video = { |
|
|
|
appendStream: (stream) => ({dom}) => { |
|
|
|
dom.autoplay = true |
|
|
|
dom.muted = true |
|
|
|
dom.srcObject = stream |
|
|
|
}, |
|
|
|
view({attrs}) { |
|
|
|
return m('.video-container', |
|
|
|
m('.video-source', attrs.username), |
|
|
|
m('video', {playsinline: true, oncreate: Video.appendStream(attrs.stream)}), |
|
|
|
) |
|
|
|
}, |
|
|
|
} |
|
|
|
const Media = { |
|
|
|
constraints: {audio: true, video: {width: {ideal: 320}, facingMode: 'user'}}, |
|
|
|
turnOn: async () => { |
|
|
|
State.media = await navigator.mediaDevices.getUserMedia(Media.constraints) |
|
|
|
wire({kind: 'peerInfo', value: {type: 'request'}}) |
|
|
|
m.redraw() |
|
|
|
}, |
|
|
|
turnOff: () => { |
|
|
|
wire({kind: 'peerInfo', value: {type: 'stop'}}) |
|
|
|
// signal internally |
|
|
|
Object.keys(rpcs).forEach(source => signal({kind: 'peerInfo', value: {type: 'stop'}, source})) |
|
|
|
State.media.getTracks().map(track => track.stop()) |
|
|
|
delete State.media |
|
|
|
}, |
|
|
|
view() { |
|
|
|
if(!State.media) { |
|
|
|
return m('.media', |
|
|
|
m('button', {onclick: Media.turnOn}, 'turn media on'), |
|
|
|
) |
|
|
|
} |
|
|
|
else { |
|
|
|
return m('.media', |
|
|
|
m('button', {onclick: Media.turnOff}, 'turn media off'), |
|
|
|
m('.videos', |
|
|
|
m(Video, {username: State.username, stream: State.media}), |
|
|
|
Object.entries(streams).map(([username, stream]) => |
|
|
|
m(Video, {username, stream}) |
|
|
|
), |
|
|
|
), |
|
|
|
) |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
const Login = { |
|
|
|
sendLogin: (e) => { |
|
|
|
e.preventDefault() |
|
|
@@ -71,13 +154,20 @@ const Login = { |
|
|
|
connect(username) |
|
|
|
}, |
|
|
|
sendLogout: (e) => { |
|
|
|
State.websocket.send(JSON.stringify({action: 'logout'})) |
|
|
|
State.media && Media.turnOff() |
|
|
|
wire({kind: 'logout'}) |
|
|
|
State.posts = [] |
|
|
|
}, |
|
|
|
view() { |
|
|
|
const attrs = { |
|
|
|
oncreate: autoFocus, |
|
|
|
name: 'username', |
|
|
|
autocomplete: 'off', |
|
|
|
value: localStorage.username, |
|
|
|
} |
|
|
|
return m('.login', |
|
|
|
m('form', {onsubmit: Login.sendLogin}, |
|
|
|
m('input', {oncreate: autoFocus, name: 'username', autocomplete: 'off', value: localStorage.username}), |
|
|
|
m('input', attrs), |
|
|
|
m('button', 'Login'), |
|
|
|
), |
|
|
|
State.kind === 'error' && m('.error', State.info), |
|
|
@@ -88,26 +178,29 @@ const Chat = { |
|
|
|
sendPost: (e) => { |
|
|
|
e.preventDefault() |
|
|
|
const field = e.target.text |
|
|
|
State.websocket.send(JSON.stringify({action: 'post', text: field.value})) |
|
|
|
field.value = '' |
|
|
|
if(field.value) { |
|
|
|
wire({kind: 'post', value: field.value}) |
|
|
|
field.value = '' |
|
|
|
} |
|
|
|
}, |
|
|
|
view() { |
|
|
|
return m('.chat', |
|
|
|
m('.posts', |
|
|
|
State.posts.map(post => m('.post', {oncreate: scrollIntoView}, |
|
|
|
m('.ts', prettyTime(post.ts)), |
|
|
|
m('.source', post.source), |
|
|
|
m('.text', post.text), |
|
|
|
m('.source', post.source || '~'), |
|
|
|
m('.text', post.value), |
|
|
|
)), |
|
|
|
), |
|
|
|
m('form.actions', {onsubmit: Chat.sendPost}, |
|
|
|
m('input', {oncreate: autoFocus, name: 'text', autocomplete: 'off'}), |
|
|
|
m('button', 'Send'), |
|
|
|
), |
|
|
|
m('.users', |
|
|
|
m('.online', |
|
|
|
m('button', {onclick: Login.sendLogout}, 'Logout'), |
|
|
|
m('ul.user-list', State.users.map(username => m('li', username))), |
|
|
|
m('ul.user-list', State.online.map(username => m('li', username))), |
|
|
|
), |
|
|
|
m(Media), |
|
|
|
) |
|
|
|
}, |
|
|
|
} |
|
|
@@ -130,27 +223,21 @@ const connect = (username) => { |
|
|
|
State.websocket = new WebSocket(wsUrl) |
|
|
|
|
|
|
|
State.websocket.onopen = (e) => { |
|
|
|
State.websocket.send(JSON.stringify({action: 'login', username})) |
|
|
|
wire({kind: 'login', value: username}) |
|
|
|
} |
|
|
|
|
|
|
|
State.websocket.onmessage = (e) => { |
|
|
|
const message = JSON.parse(e.data) |
|
|
|
if(message.kind === 'post') { |
|
|
|
State.posts.push(message) |
|
|
|
} |
|
|
|
else if(message.kind === 'update') { |
|
|
|
Object.assign(State, message) |
|
|
|
if(message.info) { |
|
|
|
State.posts.push({ts: message.ts, source: '~', text: message.info}) |
|
|
|
} |
|
|
|
} |
|
|
|
else if(message.kind === 'error') { |
|
|
|
Object.assign(State, message) |
|
|
|
Login.sendLogout() |
|
|
|
|
|
|
|
if(message.online) { |
|
|
|
State.online = message.online |
|
|
|
} |
|
|
|
else { |
|
|
|
|
|
|
|
if(!doNotLog.has(message.kind)) { |
|
|
|
console.log(message) |
|
|
|
} |
|
|
|
signal(message) |
|
|
|
|
|
|
|
m.redraw() |
|
|
|
} |
|
|
|
|