Nevar pievienot vairāk kā 25 tēmas Tēmai ir jāsākas ar burtu vai ciparu, tā var saturēt domu zīmes ('-') un var būt līdz 35 simboliem gara.

pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
pirms 6 gadiem
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. const isLandscape = screen.width > screen.height
  2. const State = {
  3. username: null,
  4. websocket: null,
  5. online: [],
  6. posts: [],
  7. rpcs: {},
  8. streams: {},
  9. options: {},
  10. }
  11. const markedOptions = {
  12. breaks: true,
  13. }
  14. marked.setOptions(markedOptions)
  15. /*
  16. *
  17. * SIGNALING
  18. *
  19. */
  20. const wire = (message) => State.websocket.send(JSON.stringify(message))
  21. const signal = (message) => dispatchEvent(new CustomEvent(message.kind, {detail: message}))
  22. const signalPeerStop = (username) => signal({kind: 'peerInfo', value: {type: 'stop'}, source: username})
  23. const listen = (kind, handler) => addEventListener(kind, handler)
  24. listen('login', ({detail}) => State.username = detail.value)
  25. listen('state', ({detail}) => Object.assign(State, detail))
  26. listen('post', ({detail}) => State.posts.push(detail))
  27. listen('peerInfo', (e) => onPeerInfo(e))
  28. const doNotLog = new Set(['login', 'state', 'post', 'peerInfo'])
  29. /*
  30. *
  31. * ALERTS
  32. *
  33. */
  34. State.unseen = 0
  35. listen('post', () => {State.unseen += !document.hasFocus(); updateTitle()})
  36. listen('focus', () => {State.unseen = 0; updateTitle()})
  37. const updateTitle = () => {
  38. document.title = `pico.chat` + (State.unseen ? ` (${State.unseen})` : ``)
  39. }
  40. /*
  41. *
  42. * WEBRTC
  43. *
  44. */
  45. const getOrCreateRpc = (username) => {
  46. if(State.username === username) {
  47. return
  48. }
  49. if(!State.rpcs[username]) {
  50. const rpc = new RTCPeerConnection({iceServers: [{urls: 'stun:stun.sipgate.net:3478'}]})
  51. rpc.onicecandidate = ({candidate}) => {
  52. if(candidate) {
  53. wire({kind: 'peerInfo', value: {type: 'candidate', candidate}})
  54. }
  55. }
  56. rpc.ontrack = (e) => {
  57. State.streams[username] = e.streams[0]
  58. m.redraw()
  59. }
  60. rpc.onclose = (e) => {
  61. console.log(username, e)
  62. }
  63. rpc.oniceconnectionstatechange = (e) => {
  64. m.redraw()
  65. }
  66. State.rpcs[username] = rpc
  67. }
  68. return State.rpcs[username]
  69. }
  70. const setSelectedMedia = async () => {
  71. const localStream = State.streams[State.username]
  72. if(!localStream) {
  73. return
  74. }
  75. const oldTracks = localStream.getTracks()
  76. const addTrack = localStream.addTrack.bind(localStream)
  77. const muted = document.querySelector('#mute-check').checked
  78. if(!muted) {
  79. const audio = Media.audioDefaults
  80. await navigator.mediaDevices.getUserMedia({audio})
  81. .then(s => s.getAudioTracks().forEach(addTrack))
  82. .catch(e => console.error(e))
  83. }
  84. const source = document.querySelector('#media-source').value
  85. if(source === 'camera') {
  86. const video = {width: {ideal: 320}, facingMode: 'user', frameRate: 26}
  87. await navigator.mediaDevices.getUserMedia({video})
  88. .then(s => s.getVideoTracks().forEach(addTrack))
  89. .catch(e => console.error(e))
  90. }
  91. if(source === 'screen' && navigator.mediaDevices.getDisplayMedia) {
  92. await navigator.mediaDevices.getDisplayMedia()
  93. .then(s => s.getVideoTracks().forEach(addTrack))
  94. .catch(e => console.error(e))
  95. }
  96. oldTracks.forEach(track => {track.stop(); localStream.removeTrack(track)})
  97. document.querySelectorAll('video').forEach(video => video.srcObject = video.srcObject)
  98. wire({kind: 'peerInfo', value: {type: 'request'}})
  99. }
  100. const onPeerInfo = async ({detail: message}) => {
  101. const localStream = State.streams[State.username]
  102. const rpc = localStream && getOrCreateRpc(message.source)
  103. const resetStreams = () => {
  104. rpc.getSenders().forEach(sender => rpc.removeTrack(sender))
  105. localStream.getTracks().forEach(track => rpc.addTrack(track, localStream))
  106. }
  107. if(rpc && message.value.type === 'request') {
  108. resetStreams()
  109. const localOffer = await rpc.createOffer()
  110. await rpc.setLocalDescription(localOffer)
  111. wire({kind: 'peerInfo', value: localOffer, target: message.source})
  112. }
  113. else if(rpc && message.value.type === 'offer') {
  114. resetStreams()
  115. const remoteOffer = new RTCSessionDescription(message.value)
  116. await rpc.setRemoteDescription(remoteOffer)
  117. const localAnswer = await rpc.createAnswer()
  118. await rpc.setLocalDescription(localAnswer)
  119. wire({kind: 'peerInfo', value: localAnswer, target: message.source})
  120. }
  121. else if(rpc && message.value.type === 'answer') {
  122. const remoteAnswer = new RTCSessionDescription(message.value)
  123. await rpc.setRemoteDescription(remoteAnswer)
  124. }
  125. else if(rpc && message.value.type === 'candidate') {
  126. const candidate = new RTCIceCandidate(message.value.candidate)
  127. rpc.addIceCandidate(candidate)
  128. }
  129. else if(message.value.type === 'stop') {
  130. if(State.streams[message.source]) {
  131. State.streams[message.source].getTracks().map(track => track.stop())
  132. delete State.streams[message.source]
  133. }
  134. if(State.rpcs[message.source]) {
  135. State.rpcs[message.source].close()
  136. delete State.rpcs[message.source]
  137. }
  138. }
  139. else if(rpc) {
  140. console.log('uncaught', message)
  141. }
  142. }
  143. /*
  144. *
  145. * GUI
  146. *
  147. */
  148. const autoFocus = (vnode) => {
  149. vnode.dom.focus()
  150. }
  151. const scrollIntoView = (vnode) => {
  152. vnode.dom.scrollIntoView()
  153. }
  154. const TextBox = {
  155. autoSize: () => {
  156. textbox.rows = textbox.value.split('\n').length
  157. },
  158. sendPost: () => {
  159. if(textbox.value) {
  160. wire({kind: 'post', value: textbox.value})
  161. textbox.value = ''
  162. textbox.focus()
  163. }
  164. },
  165. blockIndent: (text, iStart, iEnd, nLevels) => {
  166. const startLine = text.slice(0, iStart).split('\n').length - 1
  167. const endLine = text.slice(0, iEnd).split('\n').length - 1
  168. const newText = text
  169. .split('\n')
  170. .map((line, i) => {
  171. if(i < startLine || i > endLine || nLevels === 0) {
  172. newLine = line
  173. }
  174. else if(nLevels > 0) {
  175. newLine = line.replace(/^/, ' ')
  176. }
  177. else if(nLevels < 0) {
  178. newLine = line.replace(/^ /, '')
  179. }
  180. if(i === startLine) {
  181. iStart = iStart + newLine.length - line.length
  182. }
  183. iEnd = iEnd + newLine.length - line.length
  184. return newLine
  185. })
  186. .join('\n')
  187. return [newText, Math.max(0, iStart), Math.max(0, iEnd)]
  188. },
  189. hotKey: (e) => {
  190. // if isDesktop, Enter posts, unless Shift+Enter
  191. // use isLandscape as proxy for isDesktop
  192. if(e.key === 'Enter' && isLandscape && !e.shiftKey) {
  193. e.preventDefault()
  194. TextBox.sendPost()
  195. }
  196. // indent and dedent
  197. const modKey = e.ctrlKey || e.metaKey
  198. const {value: text, selectionStart: A, selectionEnd: B} = textbox
  199. if(e.key === 'Tab') {
  200. e.preventDefault()
  201. const regex = new RegExp(`([\\s\\S]{${A}})([\\s\\S]{${B - A}})`)
  202. textbox.value = text.replace(regex, (m, a, b) => a + ' '.repeat(4))
  203. textbox.setSelectionRange(A + 4, A + 4)
  204. }
  205. if(']['.includes(e.key) && modKey) {
  206. e.preventDefault()
  207. const nLevels = {']': 1, '[': -1}[e.key]
  208. const [newText, newA, newB] = TextBox.blockIndent(text, A, B, nLevels)
  209. textbox.value = newText
  210. textbox.setSelectionRange(newA, newB)
  211. }
  212. },
  213. view() {
  214. return m('.actions',
  215. m('textarea#textbox', {
  216. oncreate: (vnode) => {
  217. TextBox.autoSize()
  218. autoFocus(vnode)
  219. },
  220. onkeydown: TextBox.hotKey,
  221. onkeyup: TextBox.autoSize,
  222. }),
  223. m('button', {
  224. onclick: ({target}) => {
  225. TextBox.sendPost()
  226. TextBox.autoSize()
  227. },
  228. },
  229. 'Send'),
  230. )
  231. },
  232. }
  233. const VideoOptions = {
  234. available: ['mirror', 'square', 'full-screen'],
  235. anyFullScreen: () => {
  236. for(const username of State.online) {
  237. if(State.options[username].has('full-screen')) {
  238. return 'full-screen'
  239. }
  240. }
  241. return ''
  242. },
  243. getFor: (username) => {
  244. if(!State.options[username]) {
  245. State.options[username] = new Set(['mirror', 'square'])
  246. }
  247. return State.options[username]
  248. },
  249. getClassListFor: (username) => {
  250. return [...VideoOptions.getFor(username)].join(' ')
  251. },
  252. toggle: (options, string) => () => options.has(string)
  253. ? options.delete(string)
  254. : options.add(string),
  255. view({attrs: {username}}) {
  256. const options = VideoOptions.getFor(username)
  257. return VideoOptions.available.map((string) =>
  258. m('label.video-option',
  259. m('input', {
  260. type: 'checkbox',
  261. checked: options.has(string),
  262. onchange: VideoOptions.toggle(options, string),
  263. }),
  264. string,
  265. )
  266. )
  267. }
  268. }
  269. const Video = {
  270. keepRatio: {observe: () => {}},
  271. appendStream: ({username}) => ({dom}) => {
  272. dom.autoplay = true
  273. dom.muted = (username === State.username)
  274. dom.srcObject = State.streams[username]
  275. },
  276. view({attrs}) {
  277. const classList = VideoOptions.getClassListFor(attrs.username)
  278. const rpc = State.rpcs[attrs.username] || {iceConnectionState: null}
  279. const options = VideoOptions.getFor(attrs.username)
  280. return m('.video-container', {class: classList, oncreate: ({dom}) => Video.keepRatio.observe(dom)},
  281. m('.video-meta',
  282. m('span.video-source', attrs.username),
  283. m('.video-state', rpc.iceConnectionState),
  284. ),
  285. m('video', {
  286. playsinline: true,
  287. oncreate: Video.appendStream(attrs),
  288. ondblclick: VideoOptions.toggle(options, 'full-screen'),
  289. }),
  290. )
  291. },
  292. }
  293. if(window.ResizeObserver) {
  294. const doOne = ({target}) => target.style.setProperty('--height', `${target.clientHeight}px`)
  295. const doAll = (entries) => entries.forEach(doOne)
  296. Video.keepRatio = new ResizeObserver(doAll)
  297. }
  298. const Media = {
  299. videoSources: ['camera', 'screen', 'none'],
  300. audioDefaults: {
  301. noiseSuppresion: true,
  302. echoCancellation: true,
  303. },
  304. turnOn: async () => {
  305. State.streams[State.username] = new MediaStream()
  306. await setSelectedMedia()
  307. m.redraw()
  308. },
  309. turnOff: () => {
  310. wire({kind: 'peerInfo', value: {type: 'stop'}})
  311. State.online.forEach(signalPeerStop)
  312. },
  313. view() {
  314. return m('.media',
  315. m('.media-settings',
  316. State.streams[State.username]
  317. ? m('button', {onclick: Media.turnOff}, 'turn off')
  318. : m('button', {onclick: Media.turnOn}, 'turn on')
  319. ,
  320. m('select#media-source', {onchange: setSelectedMedia},
  321. Media.videoSources.map(option => m('option', option))
  322. ),
  323. m('label',
  324. m('input#mute-check', {onchange: setSelectedMedia, type: 'checkbox'}),
  325. 'mute'
  326. ),
  327. ),
  328. m('.videos', {className: VideoOptions.anyFullScreen()},
  329. Object.keys(State.streams).map((username) =>
  330. m(Video, {key: username, username})
  331. ),
  332. ),
  333. )
  334. },
  335. }
  336. const Login = {
  337. sendLogin: (e) => {
  338. e.preventDefault()
  339. const username = e.target.username.value
  340. localStorage.username = username
  341. connect(username)
  342. },
  343. sendLogout: (e) => {
  344. Media.turnOff()
  345. wire({kind: 'logout'})
  346. State.posts = []
  347. },
  348. view() {
  349. const attrs = {
  350. oncreate: autoFocus,
  351. name: 'username',
  352. autocomplete: 'off',
  353. value: localStorage.username,
  354. }
  355. return m('.login',
  356. m('form', {onsubmit: Login.sendLogin},
  357. m('input', attrs),
  358. m('button', 'Login'),
  359. ),
  360. m('.error', State.info),
  361. )
  362. },
  363. }
  364. const Chat = {
  365. prettifyTime: (ts) => {
  366. const dt = new Date(ts)
  367. const H = `0${dt.getHours()}`.slice(-2)
  368. const M = `0${dt.getMinutes()}`.slice(-2)
  369. const S = `0${dt.getSeconds()}`.slice(-2)
  370. return `${H}:${M}:${S}`
  371. },
  372. outboundLinks: (vnode) => {
  373. vnode.dom.querySelectorAll('a').forEach(anchor => {
  374. anchor.target = '_blank'
  375. anchor.rel = 'noopener'
  376. })
  377. },
  378. view() {
  379. return m('.chat',
  380. m('.posts',
  381. State.posts.map(post => m('.post', {oncreate: scrollIntoView},
  382. m('.ts', Chat.prettifyTime(post.ts)),
  383. m('.source', post.source || '~'),
  384. m('.text', {oncreate: Chat.outboundLinks},
  385. m.trust(DOMPurify.sanitize(marked(post.value)))
  386. ),
  387. )),
  388. ),
  389. m(TextBox),
  390. m('.online',
  391. m('button', {onclick: Login.sendLogout}, 'Logout'),
  392. m('.user-list', State.online.map(username =>
  393. m('details',
  394. m('summary', username),
  395. m(VideoOptions, {username}),
  396. ),
  397. )),
  398. ),
  399. m(Media),
  400. )
  401. },
  402. }
  403. const Main = {
  404. view() {
  405. const connected = State.websocket && State.websocket.readyState === 1
  406. return connected ? m(Chat) : m(Login)
  407. },
  408. }
  409. m.mount(document.body, Main)
  410. /*
  411. *
  412. * WEBSOCKETS
  413. *
  414. */
  415. const connect = (username) => {
  416. const wsUrl = location.href.replace('http', 'ws')
  417. State.websocket = new WebSocket(wsUrl)
  418. State.websocket.onopen = (e) => {
  419. wire({kind: 'login', value: username})
  420. }
  421. State.websocket.onmessage = (e) => {
  422. const message = JSON.parse(e.data)
  423. if(message.online) {
  424. const difference = (l1, l2) => l1.filter(u => !l2.includes(u))
  425. difference(message.online, State.online).forEach(username =>
  426. State.posts.push({ts: message.ts, value: `${username} joined`}))
  427. difference(State.online, message.online).forEach(username =>
  428. State.posts.push({ts: message.ts, value: `${username} left`}))
  429. }
  430. if(!doNotLog.has(message.kind)) {
  431. console.log(message)
  432. }
  433. signal(message)
  434. m.redraw()
  435. }
  436. State.websocket.onclose = (e) => {
  437. State.online.forEach(signalPeerStop)
  438. if(!e.wasClean) {
  439. setTimeout(connect, 1000, username)
  440. }
  441. m.redraw()
  442. }
  443. }
  444. if(localStorage.username) {
  445. connect(localStorage.username)
  446. }
  447. addEventListener('pagehide', Media.turnOff)