├── .gitignore ├── assets ├── ibmplexmono-regular-webfont.woff2 ├── ibmplexsans-regular-webfont.woff2 ├── ibmplexsans-semibold-webfont.woff2 └── qr-icon.svg ├── README.md ├── lib ├── peer-swarm.js └── webm-broadcast-stream.js ├── LICENSE ├── components ├── input.js ├── button.js ├── select.js ├── wizard.js ├── broadcast.js ├── subscribe-wizard.js ├── subscription.js └── broadcast-wizard.js ├── package.json ├── electron-main.js ├── index.html ├── index.js └── global.css /.gitignore: -------------------------------------------------------------------------------- 1 | dazaar-vision-data 2 | node_modules 3 | sandbox.js 4 | *.bundle.js 5 | .DS_Store 6 | dist 7 | -------------------------------------------------------------------------------- /assets/ibmplexmono-regular-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitfinexcom/dazaar-vision/HEAD/assets/ibmplexmono-regular-webfont.woff2 -------------------------------------------------------------------------------- /assets/ibmplexsans-regular-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitfinexcom/dazaar-vision/HEAD/assets/ibmplexsans-regular-webfont.woff2 -------------------------------------------------------------------------------- /assets/ibmplexsans-semibold-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bitfinexcom/dazaar-vision/HEAD/assets/ibmplexsans-semibold-webfont.woff2 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dazaar-vision 2 | 3 | Electron app that allows you to live stream using Dazaar 4 | 5 | ``` 6 | git clone ... 7 | npm install 8 | npm start 9 | ``` 10 | 11 | ## License 12 | 13 | MIT 14 | -------------------------------------------------------------------------------- /lib/peer-swarm.js: -------------------------------------------------------------------------------- 1 | module.exports = function (feed) { 2 | return require('@hyperswarm/replicator')(feed, { 3 | live: true, 4 | lookup: true, 5 | announce: true, 6 | onstream (protocol, info) { 7 | protocol.on('handshake', function () { 8 | info.deduplicate(protocol.publicKey, protocol.remotePublicKey) 9 | }) 10 | } 11 | }) 12 | } 13 | -------------------------------------------------------------------------------- /assets/qr-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Dazaar Limited 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /components/input.js: -------------------------------------------------------------------------------- 1 | const Component = require('hui') 2 | const html = require('hui/html') 3 | const css = require('hui/css') 4 | 5 | const style = css` 6 | :host { 7 | letter-spacing: 0.02em; 8 | color: rgba(16, 37, 66, 0.5); 9 | text-indent: 2px; 10 | display: flex; 11 | flex-direction: column; 12 | } 13 | ` 14 | 15 | const inputStyle = css` 16 | :host { 17 | color: #353248; 18 | font-size: 100%; 19 | letter-spacing: 0.02em; 20 | outline: none; 21 | border-radius: 4px; 22 | border: 0.5px solid rgba(53, 50, 72, 0.1); 23 | margin-top: 2px; 24 | } 25 | 26 | :host[disabled] { 27 | border: 0.5px solid rgba(53, 50, 72, 0.5); 28 | border-color: rgb(235, 235, 228); 29 | color: rgb(84, 84, 84); 30 | background-color: #f6f6f6; 31 | } 32 | 33 | :host.error { 34 | border: 0.5px solid #e83d4a; 35 | } 36 | ` 37 | 38 | module.exports = class Input extends Component { 39 | constructor (opts) { 40 | super() 41 | this.options = opts || {} 42 | 43 | this._input = html`` 47 | } 48 | 49 | get disabled () { 50 | return this._input.disabled 51 | } 52 | 53 | set disabled (v) { 54 | this._input.disabled = v 55 | } 56 | 57 | get readonly () { 58 | return this._input.readonly 59 | } 60 | 61 | set readonly (v) { 62 | this._input.readonly = v 63 | } 64 | 65 | set error (val) { 66 | if (val) this.element.classList.add('error') 67 | else this.element.classList.remove('error') 68 | } 69 | 70 | set value (val) { 71 | this._input.value = val 72 | } 73 | 74 | get value () { 75 | return this._input.value 76 | } 77 | 78 | createElement () { 79 | return html` 80 | 84 | ` 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /components/button.js: -------------------------------------------------------------------------------- 1 | const Component = require('hui') 2 | const html = require('hui/html') 3 | const css = require('hui/css') 4 | 5 | const style = css` 6 | :host { 7 | font-family: var(--font-main); 8 | font-size: 1rem; 9 | padding: 1rem 2.5rem; 10 | text-align: center; 11 | letter-spacing: 0.05em; 12 | background-color: #EC375B; 13 | border-radius: 2.5rem; 14 | border: none; 15 | color: #ffffff; 16 | outline: none; 17 | transition: background-color 0.25s ease; 18 | user-select: none; 19 | } 20 | 21 | :host:hover { 22 | background-color: #E91640; 23 | } 24 | 25 | :host:disabled { 26 | background-color: rgba(236, 55, 91, 0.5); 27 | } 28 | 29 | :host.border { 30 | border: 1px solid #EC375B; 31 | color: #EC375B; 32 | background: transparent; 33 | } 34 | 35 | :host.border:hover { 36 | border: 1px solid #E91640; 37 | color: #E91640; 38 | background: transparent; 39 | } 40 | 41 | :host.border-dark { 42 | border: 1px solid #fff; 43 | color: #fff; 44 | background: transparent; 45 | } 46 | 47 | :host.border-dark:disabled { 48 | border: rgba(255, 255, 255, 0.5); 49 | color: rgba(255, 255, 255, 0.5); 50 | } 51 | ` 52 | 53 | module.exports = class Button extends Component { 54 | constructor (text, opts) { 55 | if (typeof opts === 'function') { 56 | opts = { onclick: opts } 57 | } 58 | 59 | if (!opts) opts = {} 60 | 61 | super() 62 | 63 | this.text = text || '' 64 | this.onclick = opts.onclick || noop 65 | this.border = !!opts.border 66 | this.dark = !!opts.dark 67 | this.class = opts.class 68 | } 69 | 70 | createElement () { 71 | return html` 72 | 78 | ` 79 | } 80 | } 81 | 82 | function noop () {} 83 | -------------------------------------------------------------------------------- /lib/webm-broadcast-stream.js: -------------------------------------------------------------------------------- 1 | const recorder = require('media-recorder-stream') 2 | 3 | module.exports = { record, devices } 4 | 5 | function record ({ quality, video, audio }, cb) { 6 | const videoBitsPerSecond = (quality >= 3) ? 800000 : (quality === 2) ? 500000 : 200000 7 | const audioBitsPerSecond = (quality >= 3) ? 128000 : (quality === 2) ? 64000 : 32000 8 | 9 | // create MediaRecorder 10 | const opts = { 11 | interval: 1000, 12 | videoBitsPerSecond, 13 | audioBitsPerSecond 14 | } 15 | 16 | createMedia(video, audio, function (err, media) { 17 | if (err) return cb(err, null) 18 | 19 | const mediaRecorder = recorder(media, opts) 20 | 21 | cb(null, mediaRecorder) 22 | }) 23 | } 24 | 25 | function createMedia (videoDevice, audioDevice, cb) { 26 | let videoOpts = { video: true } 27 | let audioOpts = { audio: true } 28 | 29 | if (videoDevice) { 30 | // if user has selected 'screen sharing' 31 | if (videoDevice.kind === 'screen') { 32 | videoOpts = { 33 | video: { 34 | mandatory: { 35 | chromeMediaSource: 'screen', 36 | maxWidth: 1920, 37 | maxHeight: 1080, 38 | maxFrameRate: 25 39 | } 40 | } 41 | } 42 | } else { 43 | videoOpts = { video: { deviceId: { exact: videoDevice.deviceId } } } 44 | } 45 | audioOpts = { audio: { deviceId: { exact: audioDevice.deviceId } } } 46 | } 47 | 48 | // add audio stream to video stream 49 | // (allows screen sharing with audio to work) 50 | navigator.webkitGetUserMedia(audioOpts, function (audioStream) { 51 | navigator.webkitGetUserMedia(videoOpts, function (mediaStream) { 52 | mediaStream.addTrack(audioStream.getAudioTracks()[0]) 53 | process.nextTick(cb, null, mediaStream) 54 | }, onerror) 55 | }, onerror) 56 | 57 | function onerror (err) { 58 | process.nextTick(cb, err, null) 59 | } 60 | } 61 | 62 | function devices (cb) { 63 | navigator.mediaDevices.enumerateDevices() 64 | .then((devices) => process.nextTick(cb, null, devices)) 65 | .catch((err) => process.nextTick(cb, err, null)) 66 | } 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "dazaar-vision", 3 | "version": "1.2.0", 4 | "description": "Live stream video/audio over Dazaar", 5 | "main": "electron-main.js", 6 | "title": "Dazaar Vision", 7 | "browserify": { 8 | "transform": [ 9 | "hui/css", 10 | "hui/html" 11 | ] 12 | }, 13 | "build": { 14 | "copyright": "© 2019, Bitfinex", 15 | "artifactName": "${productName}.${ext}", 16 | "appId": "com.bitfinex.dazaarvision", 17 | "productName": "Dazaar Vision", 18 | "mac": { 19 | "category": "public.app-category.business", 20 | "target": "dir" 21 | }, 22 | "linux": { 23 | "target": "AppImage", 24 | "category": "Office" 25 | }, 26 | "files": [ 27 | "global.css", 28 | "assets/**", 29 | "index.bundle.js", 30 | "electron-main.js", 31 | "index.html" 32 | ] 33 | }, 34 | "dependencies": { 35 | "@dazaar/payment-lightning": "^1.1.0", 36 | "@hyperswarm/replicator": "^1.7.1", 37 | "@mafintosh/search-component": "^1.0.1", 38 | "crypto-payment-url": "^1.0.2", 39 | "dazaar": "^1.0.1", 40 | "google-auth-library": "^6.0.1", 41 | "hui": "^1.2.5", 42 | "hypercore": "^9.1.0", 43 | "hypercore-crypto": "^2.1.0", 44 | "hyperindex": "^1.0.3", 45 | "media-recorder-stream": "^2.1.1", 46 | "nanoiterator": "^1.2.1", 47 | "prettier-bytes": "^1.0.4", 48 | "pretty-ms": "^5.0.0", 49 | "pump": "^3.0.0", 50 | "streamx": "^2.6.4", 51 | "thunky": "^1.1.0", 52 | "webm-cluster-stream": "^1.0.0" 53 | }, 54 | "devDependencies": { 55 | "browserify": "^16.5.0", 56 | "electron": "^8.3.1", 57 | "electron-builder": "^22.7.0", 58 | "nanotron": "^2.2.0", 59 | "vhs-tape": "^3.2.0" 60 | }, 61 | "scripts": { 62 | "debug": "nanotron -e memcpy -e encoding index.js", 63 | "build": "browserify -u memcpy -u encoding index.js -u electron --node > index.bundle.js", 64 | "dist": "npm run build && electron-builder --publish=never --mac --win --linux", 65 | "start": "electron electron-main.js" 66 | }, 67 | "repository": { 68 | "type": "git", 69 | "url": "https://github.com/bitfinexcom/dazaar-vision.git" 70 | }, 71 | "license": "MIT", 72 | "bugs": { 73 | "url": "https://github.com/bitfinexcom/dazaar-vision/issues" 74 | }, 75 | "homepage": "https://github.com/bitfinexcom/dazaar-vision" 76 | } 77 | -------------------------------------------------------------------------------- /electron-main.js: -------------------------------------------------------------------------------- 1 | const electron = require('electron') 2 | 3 | const { BrowserWindow, app } = electron 4 | let win 5 | 6 | const userDataDir = arg('--user-data-dir') 7 | if (userDataDir) process.env.DAZAAR_VISION_USER_DATA_DIR = userDataDir 8 | 9 | app.setName('Dazaar Vision') 10 | 11 | app.on('ready', function () { 12 | win = new BrowserWindow({ 13 | width: 1280, 14 | height: 720, 15 | webPreferences: { 16 | nodeIntegration: true 17 | } 18 | }) 19 | win.loadURL('file://' + require.resolve('./index.html')) 20 | 21 | if (process.argv.includes('--dev-tools')) { 22 | win.webContents.on('did-finish-load', () => win.webContents.openDevTools({ mode: 'detach' })) 23 | win.webContents.on('context-menu', onContextMenu) 24 | } 25 | }) 26 | 27 | function onContextMenu (event, params) { 28 | const { editFlags } = params 29 | const hasText = params.selectionText.trim().length > 0 30 | const can = type => editFlags[`can${type}`] && hasText 31 | 32 | const menuTpl = [{ 33 | type: 'separator' 34 | }, { 35 | id: 'cut', 36 | label: 'Cut', 37 | // Needed because of macOS limitation: 38 | // https://github.com/electron/electron/issues/5860 39 | role: can('Cut') ? 'cut' : '', 40 | enabled: can('Cut'), 41 | visible: params.isEditable 42 | }, { 43 | id: 'copy', 44 | label: 'Copy', 45 | role: can('Copy') ? 'copy' : '', 46 | enabled: can('Copy'), 47 | visible: params.isEditable || hasText 48 | }, { 49 | id: 'paste', 50 | label: 'Paste', 51 | role: editFlags.canPaste ? 'paste' : '', 52 | enabled: editFlags.canPaste, 53 | visible: params.isEditable 54 | }, { 55 | type: 'separator' 56 | }, { 57 | id: 'inspect', 58 | label: 'Inspect Element', 59 | click () { 60 | win.inspectElement(params.x, params.y) 61 | 62 | if (win.webContents.isDevToolsOpened()) { 63 | win.webContents.devToolsWebContents.focus() 64 | } 65 | } 66 | }, { 67 | type: 'separator' 68 | }] 69 | 70 | const menu = electron.Menu.buildFromTemplate(menuTpl) 71 | menu.popup(win) 72 | } 73 | 74 | function arg (name) { 75 | for (const a of process.argv) { 76 | if (a === name) { 77 | return process.argv[process.argv.indexOf(name) + 1] 78 | } 79 | if (a.split('=')[0] === name) { 80 | return a.split('=')[1] 81 | } 82 | } 83 | return null 84 | } 85 | -------------------------------------------------------------------------------- /components/select.js: -------------------------------------------------------------------------------- 1 | const Component = require('hui') 2 | const html = require('hui/html') 3 | const css = require('hui/css') 4 | 5 | const style = css` 6 | :host { 7 | letter-spacing: 0.02em; 8 | color: rgba(16, 37, 66, 0.5); 9 | text-indent: 2px; 10 | display: flex; 11 | flex-direction: column; 12 | } 13 | ` 14 | 15 | const selectStyle = css` 16 | :host { 17 | color: #fff; 18 | letter-spacing: 0.02em; 19 | outline: none; 20 | font-size: 100%; 21 | background-color: #e83d4a; 22 | border: none; 23 | border-radius: 4px; 24 | -webkit-appearance: none; 25 | background-image: url('data:image/svg+xml;utf8,%3Csvg%20width%3D%2212%22%20height%3D%227%22%20viewBox%3D%220%200%2012%207%22%20fill%3D%22none%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M0.999999%201L6%206L11%201%22%20stroke%3D%22%23102542%22%2F%3E%3C%2Fsvg%3E'); 26 | background-repeat: no-repeat; 27 | background-position: calc(100% - 10px) 50%; 28 | padding-right: 35px; 29 | } 30 | 31 | :host.border[disabled] { 32 | border: 0.5px solid rgba(53, 50, 72, 0.5); 33 | border-color: rgb(235, 235, 228); 34 | color: rgb(84, 84, 84); 35 | background-color: #f6f6f6; 36 | } 37 | 38 | :host.border { 39 | color: #353248; 40 | background-color: white; 41 | border: 0.5px solid rgba(53, 50, 72, 0.1); 42 | } 43 | 44 | :host.error { 45 | border: 0.5px solid #e83d4a; 46 | } 47 | ` 48 | 49 | module.exports = class Select extends Component { 50 | constructor (entries, opts) { 51 | super() 52 | this.entries = entries 53 | this.options = opts || {} 54 | 55 | const options = [] 56 | if (this.options.placeholder) { 57 | options.push( 58 | html` 59 | 62 | ` 63 | ) 64 | } 65 | for (let i = 0; i < this.entries.length; i++) { 66 | options.push( 67 | html` 68 | 69 | ` 70 | ) 71 | } 72 | const onchange = this.options.onchange || noop 73 | 74 | this._select = html`` 80 | } 81 | 82 | get disabled () { 83 | return this._select.disabled 84 | } 85 | 86 | set disabled (v) { 87 | this._select.disabled = v 88 | } 89 | 90 | get readonly () { 91 | return this._select.readonly 92 | } 93 | 94 | set readonly (v) { 95 | this._select.readonly = v 96 | } 97 | 98 | get selectedIndex () { 99 | return this.options.placeholder 100 | ? this._select.selectedIndex - 1 101 | : this._select.selectedIndex 102 | } 103 | 104 | set selectedIndex (idx) { 105 | this._select.selectedIndex = idx 106 | } 107 | 108 | set error (val) { 109 | if (val) this._select.classList.add('error') 110 | else this._select.classList.remove('error') 111 | } 112 | 113 | get value () { 114 | const i = this.selectedIndex 115 | return i === -1 ? null : this.entries[i][1] 116 | } 117 | 118 | set value (val) { 119 | for (let i = 0; i < this.entries.length; i++) { 120 | const e = this.entries[i] 121 | if (e[1] === val) { 122 | if (this.options.placeholder) i++ 123 | this.selectedIndex = i 124 | return 125 | } 126 | } 127 | } 128 | 129 | createElement () { 130 | const el = html` 131 | 135 | ` 136 | 137 | return el 138 | } 139 | } 140 | 141 | function noop () {} 142 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 22 | 29 | 30 | 31 | 38 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | Dazaar logo 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /components/wizard.js: -------------------------------------------------------------------------------- 1 | const css = require('hui/css') 2 | const html = require('hui/html') 3 | const Component = require('hui') 4 | const Button = require('./button') 5 | 6 | const style = css` 7 | :host { 8 | display: grid; 9 | position: relative; 10 | grid-template-columns: 1fr 2fr; 11 | height: 100%; 12 | } 13 | 14 | :host .left { 15 | padding-left: 4rem; 16 | } 17 | :host main { 18 | background: rgba(245, 245, 246, 1); 19 | } 20 | :host .left .selected .text { 21 | font-weight: bold; 22 | } 23 | 24 | :host .left .bullet { 25 | margin-bottom: 3rem; 26 | color: #353248; 27 | font-style: normal; 28 | } 29 | :host .bullet:last-child { 30 | margin-bottom: 0; 31 | } 32 | 33 | :host .left .selected .dot { 34 | background: #E83D4A; 35 | border-color: #E83D4A; 36 | color: #FFFFFF; 37 | } 38 | 39 | :host .left .selected { 40 | font-weight: bold; 41 | } 42 | 43 | :host .left .dot { 44 | background: #fff; 45 | display: inline-block; 46 | margin-right: 1rem; 47 | font-family: var(--font-support); 48 | letter-spacing: -.1em; /*anonymous font hack*/ 49 | border: 1px solid #102542; 50 | width: 2.2rem; 51 | height: 2.2rem; 52 | line-height: 2.2rem; 53 | text-align: center; 54 | border-radius: 50%; 55 | } 56 | 57 | :host .left h3 { 58 | font-style: normal; 59 | font-weight: bold; 60 | line-height: 35px; 61 | letter-spacing: 0.02em; 62 | color: #353248; 63 | margin-bottom: 0px; 64 | } 65 | footer a { 66 | cursor: default; 67 | display: inline-block; 68 | cursor: default; 69 | text-decoration: none; 70 | font-style: normal; 71 | padding: 1rem 2rem; 72 | padding-left: 0; 73 | text-align: center; 74 | letter-spacing: 0.05em; 75 | font-weight: bold; 76 | color: #000; 77 | outline: none; 78 | user-select: none; 79 | } 80 | :host .configs { 81 | display: grid; 82 | grid-template-columns: repeat(auto-fill, minmax(20ch, 1fr)); 83 | align-items: flex-start; 84 | grid-gap: 1rem; 85 | margin-bottom: 1rem; 86 | } 87 | :host .back-arrow:before { 88 | content: ' '; 89 | transform: rotate(-45deg); 90 | display: inline-block; 91 | min-width: .5rem; 92 | min-height: .5rem; 93 | border-width: .2rem 0 0 .2rem; 94 | border-style: solid; 95 | margin-right: .5rem; 96 | margin-bottom: .15rem; 97 | } 98 | ` 99 | 100 | module.exports = class Wizard extends Component { 101 | constructor (views, opts) { 102 | super() 103 | 104 | this.views = views 105 | this._headline = (opts && opts.title) || '' 106 | this._selected = 0 107 | this.selected = 0 108 | this._bullets = null 109 | this._view = null 110 | this._end = this.views.length 111 | this.oncancel = (opts && opts.oncancel) || noop 112 | this.ondone = (opts && opts.ondone) || noop 113 | 114 | if (this.views[this._end - 1][1] === null) { 115 | this._end-- 116 | } 117 | } 118 | 119 | get value () { 120 | const data = new Array(this._end) 121 | for (let i = 0; i < data.length; i++) { 122 | data[i] = this.views[i][1].value 123 | } 124 | return data 125 | } 126 | 127 | select (i) { 128 | if (i < 0) { 129 | this.oncancel() 130 | } else if (i >= this._end) { 131 | this.ondone() 132 | } else { 133 | this.selected = i 134 | this.update() 135 | } 136 | } 137 | 138 | render () { 139 | if (this._selected === this.selected) return 140 | 141 | for (let i = 0; i <= this.selected; i++) { 142 | this._bullets[i].classList.add('selected') 143 | } 144 | 145 | for (let i = this.selected + 1; i < this._bullets.length; i++) { 146 | this._bullets[i].classList.remove('selected') 147 | } 148 | 149 | this._selected = this.selected 150 | this._view.replaceWith(this.views[this.selected][1].element) 151 | this._view = this.views[this.selected][1].element 152 | 153 | if (this._selected + 1 === this._end) { 154 | this._nextBtn.innerText = this._headline 155 | } else { 156 | this._nextBtn.innerText = 'Next' 157 | } 158 | } 159 | 160 | back () { 161 | this.select(this.selected - 1) 162 | } 163 | 164 | next () { 165 | if (this.views[this.selected][1].validate()) { 166 | this.select(this.selected + 1) 167 | } 168 | } 169 | 170 | createElement () { 171 | const bullets = this._bullets = this.views.map(([name], i) => { 172 | return html` 173 |
174 |
${i + 1}
175 | ${name} 176 |
177 | ` 178 | }) 179 | 180 | bullets[this.selected].classList.add('selected') 181 | this._view = this.views[this.selected][1].element 182 | this._nextBtn = new Button(this.selected + 1 === this._end ? this._headline : 'Next', { class: 'next-btn', onclick: this.next.bind(this) }).element 183 | 184 | return html` 185 |
186 |
187 |
188 | 191 | 194 | 195 |

DAZAAR

196 | 200 | 204 | 205 |
206 |
207 | ${bullets} 208 |
209 |
210 |
211 |
212 |

${this._headline}

213 |
214 |
215 | ${this._view} 216 |
217 |
221 | 222 | 223 | ` 224 | } 225 | } 226 | 227 | function noop () { } 228 | -------------------------------------------------------------------------------- /components/broadcast.js: -------------------------------------------------------------------------------- 1 | const Button = require('./button') 2 | const css = require('hui/css') 3 | const html = require('hui/html') 4 | const Component = require('hui') 5 | const { clipboard } = require('electron') 6 | const { record } = require('../lib/webm-broadcast-stream') 7 | const pump = require('pump') 8 | const cluster = require('webm-cluster-stream') 9 | const prettierBytes = require('prettier-bytes') 10 | 11 | const style = css` 12 | :host { 13 | position: relative; 14 | } 15 | 16 | :host video { 17 | width: 100%; 18 | height: 100%; 19 | background-size: cover; 20 | background-repeat: no-repeat; 21 | background: black; 22 | } 23 | 24 | :host .overlay { 25 | opacity: 0; 26 | transition: opacity 0.25s ease; 27 | background: rgba(0, 0, 0, 0.2); 28 | height: 100%; 29 | width: 100%; 30 | position: absolute; 31 | left: 0; 32 | top: 0; 33 | } 34 | 35 | :host:hover .overlay, 36 | :host.active .overlay { 37 | opacity: 1; 38 | } 39 | 40 | :host .overlay .bottom-right { 41 | position: absolute; 42 | right: 0; 43 | bottom: 0; 44 | margin: 20px; 45 | } 46 | 47 | :host .overlay .top-right { 48 | position: absolute; 49 | right: 0; 50 | top: 0; 51 | margin: 20px; 52 | } 53 | 54 | :host .overlay .top-left { 55 | position: absolute; 56 | left: 0; 57 | top: 0; 58 | margin: 20px; 59 | } 60 | 61 | :host .info { 62 | border-radius: 4px; 63 | background: rgba(92, 92, 108, 1); 64 | padding: 1.5rem; 65 | color: #ffffff; 66 | font-size: 14px; 67 | line-height: 22px; 68 | letter-spacing: 0.02em; 69 | } 70 | 71 | :host .info h3 { 72 | margin: 0; 73 | margin-bottom: 10px; 74 | font-weight: bold; 75 | } 76 | 77 | :host .overlay .bottom-right button { 78 | margin-left: 10px; 79 | } 80 | 81 | :host .overlay .middle { 82 | position: absolute; 83 | top: calc(80% - 35px); 84 | left: 0; 85 | right: 0; 86 | } 87 | 88 | :host h1 { 89 | font-weight: normal; 90 | margin: 0; 91 | color: #ffffff; 92 | text-align: center; 93 | width: 100%; 94 | font-size: 50px; 95 | line-height: 70px; 96 | text-align: center; 97 | letter-spacing: 0.02em; 98 | user-select: none; 99 | margin-bottom: 30px; 100 | } 101 | 102 | :host ul { 103 | list-style: none; 104 | padding: 0; 105 | margin: 0 0 1rem; 106 | } 107 | ` 108 | 109 | module.exports = class Broadcast extends Component { 110 | constructor (opts) { 111 | super() 112 | this.options = opts || {} 113 | this.seller = opts.seller 114 | this.onstop = this.options.onstop || (() => {}) 115 | this.timeout = null 116 | this.recording = null 117 | this._server = null 118 | this.swarm = null 119 | this.uploadedBytes = 0 120 | this._uploaded = html` 121 | 0 B 122 | ` 123 | this._peers = html` 124 | 0 125 | ` 126 | this._record() 127 | } 128 | 129 | _record () { 130 | const feed = this.seller.feed 131 | 132 | this.seller.on('buyer-feed', feed => { 133 | feed.on('upload', (index, data) => { 134 | this.uploadedBytes += data.length 135 | this.update() 136 | }) 137 | }) 138 | 139 | feed.ready(err => { 140 | if (err) return 141 | if (feed.length === 0) { 142 | feed.append( 143 | JSON.stringify({ 144 | description: this.options.description, 145 | quality: this.options.quality, 146 | video: this.options.video.deviceId, 147 | audio: this.options.audio.deviceId, 148 | payment: this.options.payment 149 | }) 150 | ) 151 | } 152 | 153 | record( 154 | { 155 | quality: this.options.quality, 156 | video: this.options.video, 157 | audio: this.options.audio 158 | }, 159 | (err, stream) => { 160 | if (err) return 161 | 162 | if (!this.seller) return stream.destroy() 163 | this.recording = stream 164 | pump(stream, cluster(), feed.createWriteStream()) 165 | 166 | this._server = require('http').createServer( 167 | this._onrequest.bind(this) 168 | ) 169 | this._server.listen(0, '127.0.0.1') 170 | this.once(this._server, 'listening', this.start.bind(this)) 171 | this.swarm = require('dazaar/swarm')(this.seller) 172 | this.swarm.on('connection', () => { 173 | this.update() 174 | }) 175 | this.swarm.on('disconnection', () => { 176 | this.update() 177 | }) 178 | } 179 | ) 180 | }) 181 | } 182 | 183 | render () { 184 | this._uploaded.innerText = prettierBytes(this.uploadedBytes) 185 | this._peers.innerText = this.swarm ? this.swarm.connections.size : 0 186 | } 187 | 188 | _onrequest (req, res) { 189 | const self = this 190 | this.recording.on('data', ondata) 191 | res.on('close', done) 192 | res.on('error', done) 193 | res.on('end', done) 194 | req.on('close', done) 195 | req.on('error', done) 196 | req.on('end', done) 197 | 198 | function done () { 199 | self.recording.removeListener('data', ondata) 200 | } 201 | 202 | function ondata (data) { 203 | res.write(data) 204 | } 205 | } 206 | 207 | start () { 208 | const video = this.element.querySelector('video') 209 | video.src = 'http://127.0.0.1:' + this._server.address().port 210 | video.play() 211 | } 212 | 213 | onload () { 214 | this.element.classList.add('active') 215 | this.timeout = setTimeout(() => { 216 | this.element.classList.remove('active') 217 | }, 5000) 218 | } 219 | 220 | onunload () { 221 | clearTimeout(this.timeout) 222 | } 223 | 224 | stop () { 225 | if (this.recording) this.recording.destroy() 226 | 227 | const video = this.element.querySelector('video') 228 | video.src = '' 229 | 230 | if (this.swarm) this.swarm.destroy() 231 | 232 | this.seller.feed.close(() => { 233 | this._server.close() 234 | this._server.on('close', () => { 235 | this.onstop() 236 | }) 237 | }) 238 | this.seller = null 239 | } 240 | 241 | copy () { 242 | if (!this.seller) return 243 | this.seller.ready(err => { 244 | if (err) return 245 | 246 | const card = { 247 | id: this.seller.key.toString('hex'), 248 | description: this.options.description, 249 | payment: null 250 | } 251 | 252 | if (this.options.payment) { 253 | card.payment = [this.options.payment] 254 | } 255 | 256 | console.log(JSON.stringify(card, null, 2)) 257 | clipboard.writeText(JSON.stringify(card, null, 2)) 258 | }) 259 | } 260 | 261 | createElement () { 262 | return html` 263 |
264 | 265 |
266 |
267 | ${new Button('Stop broadcasting', { onclick: this.stop.bind(this) }) 268 | .element} 269 |
270 |
271 |

${this.options.description || 'Video stream'}

272 |
    273 |
  • Connected to ${this._peers} peer(s)
  • 274 |
  • Uploaded ${this._uploaded}
  • 275 |
  • 276 | ${this.options.payment 277 | ? 'You are charging for this stream' 278 | : 'Stream is free of charge'} 279 |
  • 280 |
281 | ${new Button('Copy Dazaar card', { onclick: this.copy.bind(this) }) 282 | .element} 283 |
284 |
285 |

You are broadcasting

286 |
287 |
288 |
289 | ` 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /components/subscribe-wizard.js: -------------------------------------------------------------------------------- 1 | const css = require('hui/css') 2 | const html = require('hui/html') 3 | const Component = require('hui') 4 | const Wizard = require('./wizard') 5 | const Search = require('@mafintosh/search-component') 6 | const HyperIndex = require('hyperindex') 7 | const replicator = require('@hyperswarm/replicator') 8 | const nanoiterator = require('nanoiterator') 9 | const path = require('path') 10 | 11 | const DEFAULT_INDEX = 'a943feb19afccebb16168094cf29601f500056ea8ad629ab601abdcb278674f8' 12 | 13 | const style = css` 14 | :host input[type=search] { 15 | grid-column: span 2; 16 | background: url('data:image/svg+xml;utf8,') no-repeat; 17 | background-position: 1em center; 18 | background-size: 1em 90%; 19 | color: #353248; 20 | padding: .69em .69em .69em 2.69em; 21 | font-size: 100%; 22 | letter-spacing: 0.02em; 23 | outline: none; 24 | border-radius: 4px; 25 | border: 0.5px solid rgba(53, 50, 72, 0.1); 26 | width: 310px; 27 | background-color: white; 28 | } 29 | 30 | :host .index-key { 31 | color: #353248; 32 | padding: .39em .39em .39em .39em; 33 | font-size: .60em; 34 | letter-spacing: 0.02em; 35 | outline: none; 36 | border-radius: 4px; 37 | background-color: white; 38 | border: 0.5px solid rgba(53, 50, 72, 0.1); 39 | margin-left: 20px; 40 | width: 250px; 41 | } 42 | 43 | :host .result-item.selected .result-selected { 44 | opacity: 1; 45 | } 46 | 47 | :host .result-selected { 48 | opacity: 0; 49 | background-color: #EC375B; 50 | width: 18px; 51 | height: 18px; 52 | border-radius: 9px; 53 | margin: auto; 54 | margin-right: 10px; 55 | } 56 | 57 | :host .result-item { 58 | border-top: 1px rgba(53, 50, 72, 0.1) solid; 59 | display: flex; 60 | padding: .50em 0; 61 | } 62 | 63 | :host .result-item:hover { 64 | background-color: rgba(196, 196, 196, 0.3); 65 | } 66 | 67 | :host .stream-name { 68 | font-weight: bold; 69 | margin-right: 10px; 70 | max-width: 50%; 71 | text-overflow: ellipsis; 72 | display: inline-block; 73 | overflow: hidden; 74 | white-space: nowrap; 75 | } 76 | 77 | :host .stream-key { 78 | display: inline-block; 79 | max-width: 12ch; 80 | margin-right: 10px; 81 | } 82 | 83 | :host .stream-price { 84 | flex: auto; 85 | text-align: right; 86 | margin-right: 10px; 87 | white-space: nowrap; 88 | } 89 | 90 | :host .results { 91 | margin-top: 20px; 92 | max-height: calc(100vh - 560px); 93 | min-height: 5rem; 94 | overflow-y: scroll; 95 | } 96 | 97 | :host .configure { 98 | margin-left: 20px; 99 | font-size: 12px; 100 | font-weight: normal; 101 | } 102 | 103 | :host .configure:hover { 104 | text-decoration: underline; 105 | } 106 | ` 107 | 108 | function streamToIterator (stream) { 109 | return nanoiterator({ 110 | next (cb) { 111 | const data = stream.read() 112 | if (data) return cb(null, data) 113 | stream.once('readable', () => cb(null, stream.read())) 114 | } 115 | }) 116 | } 117 | 118 | class SearchContainer extends Component { 119 | constructor (settings) { 120 | super() 121 | this.settings = settings 122 | this.configuring = false 123 | this.search = null 124 | } 125 | 126 | render () { 127 | if (this.configuring) { 128 | this.element.querySelector('.index-key').style.display = '' 129 | this.element.querySelector('.configure').style.display = 'none' 130 | } else { 131 | this.element.querySelector('.index-key').style.display = 'none' 132 | this.element.querySelector('.configure').style.display = '' 133 | if (this.search) { 134 | this.element.removeChild(this.search.element) 135 | this.search = new SearchWizard(this.element.querySelector('input[type=search]'), this.settings) 136 | this.element.appendChild(this.search.element) 137 | } 138 | } 139 | } 140 | 141 | get value () { 142 | return this.search.value 143 | } 144 | 145 | validate () { 146 | return !!this.value 147 | } 148 | 149 | createElement () { 150 | const self = this 151 | const inp = document.createElement('input') 152 | inp.type = 'text' 153 | inp.placeholder = 'Search for name or Dazaar card' 154 | const s = this.search = new SearchWizard(inp, this.settings) 155 | const conf = html`` 156 | 157 | conf.value = self.settings.data.search || '' 158 | 159 | conf.oninput = function () { 160 | const v = conf.value.trim() 161 | if (!/[a-f0-9]{64}/i.test(v)) return 162 | self.settings.data.search = v 163 | self.settings.save() 164 | self.configuring = false 165 | self.update() 166 | } 167 | 168 | return html` 169 |
170 |
171 |

Search StreamConfigure search index${conf}

172 | ${inp} 173 |
174 | ${s.element} 175 |
176 | ` 177 | 178 | function onconfig () { 179 | self.configuring = true 180 | self.update() 181 | } 182 | } 183 | } 184 | 185 | class SearchWizard extends Search { 186 | constructor (input, settings) { 187 | super({ 188 | input, 189 | query (q) { 190 | if (!q) return 191 | if (/^[a-f0-9]{64}$/.test(q)) q = '{"id":"' + q + '"}' 192 | if (q[0] === '{') { 193 | let data 194 | try { 195 | data = JSON.parse(q) 196 | } catch (_) {} 197 | 198 | if (data && data.id) { 199 | let once = true 200 | return nanoiterator({ 201 | next (cb) { 202 | if (once) { 203 | once = false 204 | return cb(null, data) 205 | } 206 | return cb(null, null) 207 | } 208 | }) 209 | } 210 | } 211 | const words = q.split(/\s+/) 212 | return streamToIterator(self.idx.or(...words)) 213 | }, 214 | result (data) { 215 | let payment = 'Free' 216 | 217 | if (data.payment) { 218 | const first = data.payment[0] || data.payment 219 | payment = first.amount + ' ' + first.currency + ' per ' + first.interval + ' ' + first.unit 220 | } 221 | 222 | // only needed cause we generated the test data wrongly 223 | if ((data.key && !data.id) || typeof data.id === 'number') data.id = data.key 224 | 225 | return html` 226 |
227 | 228 | 229 | 230 | ${data.description} 231 | 232 | 233 | ${data.id.slice(0, 6) + '...' + data.id.slice(-2)} 234 | 235 | 236 | ${payment} 237 | 238 |
239 | ` 240 | 241 | function onclick () { 242 | if (self.selectedElement) self.selectedElement.classList.remove('selected') 243 | this.classList.add('selected') 244 | self.selectedElement = this 245 | self.value = data 246 | } 247 | } 248 | }) 249 | 250 | const self = this 251 | 252 | this.value = null 253 | this.selectedElement = null 254 | this.swarm = null 255 | this.settings = settings 256 | const index = settings.data.search || DEFAULT_INDEX 257 | this.idx = new HyperIndex(path.join(settings.dataPath, 'search', index), Buffer.from(index, 'hex'), { 258 | valueEncoding: 'json', 259 | alwaysUpdate: true, 260 | sparse: true 261 | }) 262 | } 263 | 264 | onload () { 265 | this.swarm = replicator(this.idx.trie, { 266 | live: true, 267 | lookup: true, 268 | announce: false 269 | }) 270 | } 271 | 272 | onunload () { 273 | this.swarm.destroy() 274 | this.swarm = null 275 | this.idx.trie.feed.close() 276 | } 277 | 278 | validate () { 279 | return !!this.value 280 | } 281 | } 282 | 283 | module.exports = class SubscribeWizard extends Wizard { 284 | constructor (opts = {}) { 285 | const search = new SearchContainer(opts.settings) 286 | 287 | super( 288 | [ 289 | ['Search stream', search], 290 | ['View stream', null] 291 | ], 292 | { 293 | title: 'Subscribe to Stream', 294 | ...opts 295 | } 296 | ) 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const Button = require('./components/button') 2 | const css = require('hui/css') 3 | const html = require('hui/html') 4 | 5 | const BroadcastWizard = require('./components/broadcast-wizard') 6 | const SubscribeWizard = require('./components/subscribe-wizard') 7 | const Broadcast = require('./components/broadcast') 8 | const Subscription = require('./components/subscription') 9 | const hypercore = require('hypercore') 10 | const crypto = require('hypercore-crypto') 11 | const electron = require('electron') 12 | const path = require('path') 13 | const userDataPath = (electron.app || electron.remote.app).getPath('userData') 14 | const dataPath = process.env.DAZAAR_VISION_USER_DATA_DIR || path.join(userDataPath, './dazaar-vision-data') 15 | const dazaar = require('dazaar')(dataPath) 16 | const Payment = require('@dazaar/payment-lightning') 17 | const fs = require('fs') 18 | const thunky = require('thunky') 19 | 20 | let loadDefaultConfig = thunky(loadConf) 21 | console.log('Data path is ' + dataPath) 22 | 23 | class Settings { 24 | constructor (dataPath) { 25 | this.dataPath = path.resolve(dataPath) 26 | this.settingsPath = path.join(this.dataPath, 'settings.json') 27 | try { 28 | this.data = require(this.settingsPath) 29 | } catch (_) { 30 | this.data = {} 31 | } 32 | } 33 | 34 | save (cb) { 35 | if (!cb) cb = () => {} 36 | const p = this.settingsPath 37 | fs.writeFile(p + '.tmp', JSON.stringify(this.data, null, 2), function (err) { 38 | if (err) return cb(err) 39 | fs.rename(p + '.tmp', p, cb) 40 | }) 41 | } 42 | } 43 | 44 | console.log('Storing data in', dataPath) 45 | 46 | const settings = new Settings(dataPath) 47 | const style = css` 48 | :host { 49 | display: grid; 50 | grid-template-columns: 1fr 2fr; 51 | } 52 | :host button { 53 | margin-right: 1.4rem; 54 | margin-bottom: 1.4rem; 55 | } 56 | ` 57 | const main = html` 58 |
59 | 65 | 66 | 67 | 72 | 73 | 76 | 81 | 82 | 83 | 91 | 92 | 93 | 94 |
95 |

96 | Welcome to
Dazaar Vision 97 |

98 |

Choose how you want to use Dazaar.

99 |
100 | ${new Button('Start broadcast', { onclick: broadcast }).element} 101 | ${new Button('Subscribe to stream', { onclick: subscribe, border: true }).element} 102 |
103 | 110 | 114 | 115 |
116 |
117 | ` 118 | let cycleColor = true 119 | function bumpColor () { 120 | if (cycleColor) { 121 | const hue = Number(document.body.style.getPropertyValue('--hue')) || 0 122 | document.body.style.setProperty('--hue', (hue + 1) % 360) 123 | setTimeout(() => window.requestAnimationFrame(bumpColor), 100) 124 | } else { 125 | document.body.style.removeProperty('--hue') 126 | } 127 | } 128 | window.requestAnimationFrame(bumpColor) 129 | let view = main 130 | document.body.appendChild(main) 131 | 132 | // Export mute functions 133 | window.mute = mute 134 | 135 | function subscribe () { 136 | cycleColor = false 137 | const sw = new SubscribeWizard({ 138 | settings, 139 | // list (cb) { 140 | // dazaar.buying(function (err, keys) { 141 | // if (err) return cb(err) 142 | // loadInfo(keys, true, cb) 143 | // }) 144 | // }, 145 | ondone () { 146 | const card = sw.value[0] 147 | const buyer = dazaar.buy(Buffer.from(card.id, 'hex'), { sparse: true }) 148 | const s = new Subscription({ 149 | buyer, 150 | payment: card.payment, 151 | onstop () { 152 | changeMainView(main) 153 | } 154 | }) 155 | 156 | changeMainView(s.element) 157 | }, 158 | oncancel () { 159 | changeMainView(main) 160 | } 161 | }) 162 | 163 | changeMainView(sw.element) 164 | } 165 | 166 | function broadcast () { 167 | cycleColor = false 168 | 169 | loadDefaultConfig(function (_, defaultConfig) { 170 | const bw = new BroadcastWizard({ 171 | defaultConfig, 172 | list (cb) { 173 | dazaar.selling(function (err, keys) { 174 | if (err) return cb(err) 175 | loadInfo(keys, false, cb) 176 | }) 177 | }, 178 | ondone () { 179 | const [existing, p, devices] = bw.value 180 | const payment = p && p.payment 181 | const config = p && p.config 182 | 183 | const feed = createFeed(existing && existing.feed) 184 | let pay = null 185 | const seller = dazaar.sell(feed, { 186 | validate (remoteKey, done) { 187 | console.log('validate', remoteKey, payment) 188 | if (!payment) return done(null, { type: 'free', uniqueFeed: seller.uniqueFeed }) 189 | pay.validate(remoteKey, function (err, info) { 190 | console.log('done', err, info) 191 | done(err, info) 192 | }) 193 | } 194 | }) 195 | 196 | if (payment) dazaar.setConfig(payment.currency, config) 197 | else seller.uniqueFeed = false 198 | 199 | seller.ready(function () { 200 | if (payment) pay = new Payment(seller, payment, config) 201 | }) 202 | 203 | const b = new Broadcast({ 204 | payment, 205 | video: devices.video, 206 | audio: devices.audio, 207 | quality: devices.quality, 208 | description: devices.description, 209 | seller, 210 | onstop () { 211 | if (pay) pay.destroy() 212 | changeMainView(main) 213 | } 214 | }) 215 | 216 | changeMainView(b.element) 217 | }, 218 | oncancel () { 219 | changeMainView(main) 220 | } 221 | }) 222 | 223 | changeMainView(bw.element) 224 | }) 225 | } 226 | 227 | function mute () { 228 | const v = document.querySelector('video') 229 | if (v) v.muted = true 230 | } 231 | 232 | function changeMainView (el) { 233 | if (el === main) { 234 | loadDefaultConfig = thunky(loadConf) 235 | loadDefaultConfig() 236 | } 237 | // TODO: raf me 238 | view.replaceWith(el) 239 | view = el 240 | } 241 | 242 | function createFeed (publicKey, buyer) { 243 | const keys = !publicKey ? crypto.keyPair() : null 244 | if (keys) publicKey = keys.publicKey 245 | 246 | // TODO: make the storage function a public api that's always namespaced 247 | return hypercore( 248 | name => 249 | dazaar._storage( 250 | (buyer ? 'buys/' : 'streams/') + publicKey.toString('hex') + '/' + name 251 | ), 252 | publicKey, 253 | { 254 | secretKey: keys && keys.secretKey 255 | } 256 | ) 257 | } 258 | 259 | function loadInfo (keys, buyer, cb) { 260 | let i = 0 261 | const result = [] 262 | loop() 263 | 264 | function loop () { 265 | if (i >= keys.length) return cb(null, result) 266 | const k = keys[i++] 267 | const feed = createFeed(k.feed, buyer) 268 | 269 | feed.get(0, { wait: false }, function (err, data) { 270 | if (err) return loop() 271 | try { 272 | data = JSON.parse(data) 273 | } catch (_) { 274 | data = {} 275 | } 276 | 277 | if (!data.payment) return onconfig(null, null) 278 | dazaar.getConfig(data.payment.currency, onconfig) 279 | 280 | function onconfig (err, config) { 281 | if (err) return feed.close(loop) 282 | 283 | result.push({ 284 | key: k.key, 285 | feed: k.feed, 286 | config, 287 | ...data 288 | }) 289 | 290 | feed.close(loop) 291 | } 292 | }) 293 | } 294 | } 295 | 296 | function loadConf (cb) { 297 | const defaults = {} 298 | dazaar.getConfig('LightningSats', function (_, conf) { 299 | if (conf) defaults.LightningSats = conf 300 | cb(null, defaults) 301 | }) 302 | } 303 | -------------------------------------------------------------------------------- /components/subscription.js: -------------------------------------------------------------------------------- 1 | const Button = require('./button') 2 | const css = require('hui/css') 3 | const html = require('hui/html') 4 | const rawHtml = require('hui/html/raw') 5 | const Component = require('hui') 6 | const Input = require('./input') 7 | const peerSwarm = require('../lib/peer-swarm') 8 | const pump = require('pump') 9 | const Payment = require('@dazaar/payment-lightning') 10 | const prettierBytes = require('prettier-bytes') 11 | const prettyMilliseconds = require('pretty-ms') 12 | const qr = require('crypto-payment-url/qrcode') 13 | const { clipboard } = require('electron') 14 | const { Readable } = require('streamx') 15 | 16 | class Throttle extends Readable { 17 | constructor (feed, start) { 18 | super() 19 | this.feed = feed 20 | this.start = start 21 | this.range = feed.download({ start, end: start + 16, linear: true }) 22 | } 23 | 24 | _read (cb) { 25 | const start = this.start 26 | this.feed.undownload(this.range) 27 | this.range = this.feed.download({ start, end: start + 16, linear: true }) 28 | this.feed.get(this.start++, (err, data) => { 29 | if (err) return cb(err) 30 | this.push(data) 31 | cb(null) 32 | }) 33 | } 34 | 35 | _destroy (cb) { 36 | this.feed.undownload(this.range) 37 | cb(null) 38 | } 39 | } 40 | 41 | const PLAY = ` 42 | 43 | 44 | 45 | 46 | ` 47 | 48 | const PAUSE = ` 49 | 50 | 51 | 52 | 53 | ` 54 | 55 | const style = css` 56 | :host { 57 | position: relative; 58 | } 59 | 60 | :host video { 61 | width: 100%; 62 | height: 100%; 63 | background-size: cover; 64 | background-repeat: no-repeat; 65 | background: black; 66 | } 67 | 68 | :host h1 { 69 | margin: 0; 70 | color: #ffffff; 71 | text-align: center; 72 | width: 100%; 73 | font-size: 35px; 74 | text-align: center; 75 | letter-spacing: 0.02em; 76 | user-select: none; 77 | } 78 | 79 | :host .overlay { 80 | opacity: 0; 81 | transition: opacity 0.25s ease; 82 | background: rgba(0, 0, 0, 0.2); 83 | height: 100%; 84 | width: 100%; 85 | position: absolute; 86 | left: 0; 87 | top: 0; 88 | } 89 | 90 | :host.active .overlay, 91 | :host:hover .overlay { 92 | opacity: 1; 93 | } 94 | 95 | :host .overlay .bottom { 96 | position: absolute; 97 | right: 0; 98 | bottom: 20px; 99 | left: 0; 100 | padding: 20px; 101 | } 102 | 103 | :host .overlay .controls { 104 | background-color: rgba(92, 92, 108, 1); 105 | } 106 | 107 | :host .overlay .pause-play { 108 | position: absolute; 109 | width: 100px; 110 | height: 100px; 111 | left: calc(50% - 50px); 112 | top: calc(50% - 50px); 113 | } 114 | 115 | :host .overlay .pause-play svg { 116 | width: 100%; 117 | height: 100%; 118 | fill: rgba(92, 92, 108, 1); 119 | color: rgba(92, 92, 108, 1); 120 | } 121 | 122 | :host .overlay .top-right { 123 | position: absolute; 124 | right: 0; 125 | top: 0; 126 | margin: 20px; 127 | } 128 | 129 | :host .overlay .top-left { 130 | position: absolute; 131 | left: 0; 132 | top: 0; 133 | margin: 20px; 134 | min-width: 250px; 135 | } 136 | 137 | :host .info { 138 | border-radius: 4px; 139 | background: rgba(92, 92, 108, 1); 140 | padding: 10px; 141 | color: #ffffff; 142 | font-size: 14px; 143 | line-height: 22px; 144 | letter-spacing: 0.02em; 145 | text-align: center; 146 | } 147 | 148 | :host .info h3 { 149 | margin: 0; 150 | margin-bottom: 10px; 151 | font-weight: bold; 152 | } 153 | 154 | :host .overlay .bottom-right button { 155 | margin-left: 10px; 156 | } 157 | 158 | :host ul { 159 | list-style: none; 160 | padding: 0; 161 | margin: 0; 162 | } 163 | 164 | :host input { 165 | padding: 10px; 166 | border-color: white; 167 | width: 190px; 168 | } 169 | 170 | :host .overlay .middle { 171 | position: absolute; 172 | top: calc(50% - 35px); 173 | left: 0; 174 | right: 0; 175 | } 176 | ` 177 | 178 | module.exports = class Subscription extends Component { 179 | constructor (opts) { 180 | super() 181 | this.options = opts 182 | this.buyer = this.options.buyer 183 | 184 | this.payment = new Payment(this.buyer, null) 185 | 186 | this.onstop = this.options.onstop || noop 187 | this._desc = html` 188 | Waiting for description 189 | ` 190 | this._info = html` 191 | Waiting for remote info 192 | ` 193 | this._downloaded = html` 194 | 0 B 195 | ` 196 | this._peers = html` 197 | 0 198 | ` 199 | this._server = null 200 | this._gotoEnd = true 201 | this._invoiceEl = null 202 | this.currentFrame = 0 203 | this._serverStream = null 204 | this._amount = null 205 | this._timeout = null 206 | this.downloadBytes = 0 207 | this.swarm = null 208 | this.peerSwarm = null 209 | this.playing = true 210 | this._playButtonRerender = false 211 | this._subscribe() 212 | } 213 | 214 | _subscribe () { 215 | const self = this 216 | 217 | if (this.buyer.feed) onfeed(this.buyer.feed) 218 | else this.buyer.on('feed', () => onfeed(this.buyer.feed)) 219 | 220 | this.buyer.ready(() => { 221 | this.swarm = require('dazaar/swarm')(this.buyer) 222 | this.swarm.on('connection', () => { 223 | this.update() 224 | }) 225 | this.swarm.on('disconnection', () => { 226 | this.update() 227 | }) 228 | }) 229 | 230 | let hoverState = false 231 | 232 | this.buyer.on('invalid', err => { 233 | this._info.innerText = err.message 234 | this.element.classList.add('active') 235 | hoverState = true 236 | clearTimeout(this._timeout) 237 | }) 238 | 239 | this.buyer.on('valid', info => { 240 | if (info && info.uniqueFeed === false && !this.peerSwarm) { 241 | this.peerSwarm = peerSwarm(this.buyer.feed) 242 | this.peerSwarm.on('connection', () => { 243 | this.update() 244 | }) 245 | this.peerSwarm.on('disconnection', () => { 246 | this.update() 247 | }) 248 | } 249 | this._info.innerText = infoMessage(info) 250 | if (!hoverState) return 251 | this._timeout = setTimeout(() => this.element.classList.remove('active'), 1000) 252 | }) 253 | 254 | function onfeed (feed) { 255 | feed.get(0, (_, data) => { 256 | if (data) { 257 | try { 258 | const info = JSON.parse(data) 259 | // TODO: raf me 260 | if (info.description) self._desc.innerText = info.description 261 | if (info.tail === false || info.gotoEnd === false) self._gotoEnd = false 262 | } catch (_) {} 263 | } 264 | }) 265 | 266 | feed.on('download', function (index, data) { 267 | self.downloadBytes += data.length 268 | self.update() 269 | }) 270 | 271 | if (self._server) return 272 | self._server = require('http').createServer(self._onrequest.bind(self)) 273 | self._server.listen(0, '127.0.0.1') 274 | self.once(self._server, 'listening', self.start.bind(self)) 275 | } 276 | } 277 | 278 | render () { 279 | this._downloaded.innerText = prettierBytes(this.downloadBytes) 280 | this._peers.innerText = (this.swarm ? this.swarm.connections.size : 0) + (this.peerSwarm ? this.peerSwarm.connections.size : 0) 281 | 282 | if (this._playButtonRerender) { 283 | this._playButtonRerender = false 284 | if (this.playing) { 285 | this.element.querySelector('.pause-play').innerHTML = PAUSE 286 | } else { 287 | this.element.querySelector('.pause-play').innerHTML = PLAY 288 | } 289 | } 290 | } 291 | 292 | onload () { 293 | this.on(document.body, 'keydown', (e) => { 294 | if (e.keyCode === 32) this.togglePlay() 295 | }) 296 | 297 | this.on(this.element.querySelector('.pause-play'), 'click', () => { 298 | this.togglePlay() 299 | }) 300 | } 301 | 302 | togglePlay () { 303 | this._playButtonRerender = true 304 | if (this.playing) { 305 | this.playing = false 306 | this.element.querySelector('video').pause() 307 | } else { 308 | this.playing = true 309 | this.element.querySelector('video').play() 310 | } 311 | this.update() 312 | } 313 | 314 | _onrequest (req, res) { 315 | const feed = this.buyer.feed 316 | feed.get(1, (err, data) => { 317 | if (err || !this.loaded) return res.destroy() 318 | res.write(data) 319 | 320 | feed.update({ ifAvailable: true }, () => { 321 | if (!this.loaded) return res.destroy() 322 | 323 | let start = Math.max(2, feed.length - 1) 324 | if (!this._gotoEnd) start = 2 325 | 326 | const stream = new Throttle(feed, start) 327 | 328 | this.currentFrame = start 329 | this._serverStream = stream 330 | 331 | pump(stream, res) 332 | }) 333 | }) 334 | } 335 | 336 | start () { 337 | const video = this.element.querySelector('video') 338 | video.src = 'http://127.0.0.1:' + this._server.address().port 339 | video.play() 340 | } 341 | 342 | stop () { 343 | if (this.buyer.feed) this.buyer.feed.close() 344 | if (this.swarm) this.swarm.destroy() 345 | if (this.peerSwarm) this.peerSwarm.destroy() 346 | if (this._server) this._server.close() 347 | const video = this.element.querySelector('video') 348 | video.src = '' 349 | this.onstop() 350 | } 351 | 352 | gotoStart () { 353 | this._gotoEnd = false 354 | if (this._serverStream) this._serverStream.destroy() 355 | if (this._server) this.start() 356 | } 357 | 358 | gotoEnd () { 359 | this._gotoEnd = true 360 | if (this._serverStream) this._serverStream.destroy() 361 | if (this._server) this.start() 362 | } 363 | 364 | buy (amount) { 365 | const self = this 366 | 367 | this.payment.requestInvoice(amount, function (err, inv) { 368 | if (err) throw err 369 | 370 | self._invoiceEl.style.display = 'block' 371 | const a = self._invoiceEl.querySelector('.qrcode') 372 | const { url, qrcode } = qr.bitcoin({ lightning: inv.request }) 373 | a.href = url 374 | a.innerHTML = qrcode 375 | 376 | const span = self._invoiceEl.querySelector('.amount') 377 | span.innerText = amount + ' Satoshis' 378 | }) 379 | } 380 | 381 | createElement () { 382 | const amount = (this._amount = new Input({ 383 | placeholder: 'Enter Satoshis', 384 | style: 'margin: 10px auto;' 385 | })) 386 | 387 | const invoiceEl = this._invoiceEl = html` 388 |
389 |

Scan or click to open LN invoice

390 | 391 |
392 | 0 Satoshis 393 | Copy invoice 394 |
395 |
396 | ` 397 | 398 | function onclick (e) { 399 | const invoice = invoiceEl.querySelector('.qrcode').href.split('lightning=')[1] 400 | console.log(invoice) 401 | clipboard.writeText(invoice) 402 | e.preventDefault() 403 | } 404 | 405 | return html` 406 |
407 | 408 |
409 |
410 | ${new Button('Stop watching', { onclick: this.stop.bind(this) }) 411 | .element} 412 |
413 |
414 | ${new Button('Go to start', { 415 | dark: true, 416 | border: true, 417 | onclick: this.gotoStart.bind(this) 418 | }).element} 419 |
420 |

${this._info}

421 |
422 | ${new Button('Go to end', { 423 | dark: true, 424 | border: true, 425 | onclick: this.gotoEnd.bind(this) 426 | }).element} 427 |
428 |
429 | ${rawHtml(PAUSE)} 430 |
431 |
432 |

${this._desc}

433 |
    434 |
  • Connected to ${this._peers} peer(s)
  • 435 |
  • Downloaded ${this._downloaded}
  • 436 |
437 |
438 | ${amount.element} 439 | ${new Button('Buy', { 440 | onclick: () => this.buy(Number(amount.value)) 441 | }).element} 442 |
443 | ${invoiceEl} 444 |
445 |
446 |
447 | ` 448 | } 449 | } 450 | 451 | function noop () {} 452 | 453 | function infoMessage (info) { 454 | if (info) { 455 | if (info.type === 'free') { 456 | return 'Stream is free of charge' 457 | } else if (info.type === 'time') { 458 | return ( 459 | 'Subscription expires in ' + 460 | prettyMilliseconds(info.remaining, { compact: true }) 461 | ) 462 | } else { 463 | return 'Unknown subscription type: ' + info.type 464 | } 465 | } else { 466 | return 'Remote did not share any subscription info' 467 | } 468 | } 469 | -------------------------------------------------------------------------------- /components/broadcast-wizard.js: -------------------------------------------------------------------------------- 1 | const css = require('hui/css') 2 | const html = require('hui/html') 3 | const Component = require('hui') 4 | const path = require('path') 5 | const fs = require('fs') 6 | const os = require('os') 7 | const Select = require('./select') 8 | const Input = require('./input') 9 | const Wizard = require('./wizard') 10 | const HAS_ZAP = fs.existsSync('/Applications/Zap.app') 11 | const { devices } = require('../lib/webm-broadcast-stream.js') 12 | 13 | // allow setting this in the console 14 | window.LND_NETWORK = 'mainnet' 15 | 16 | const cls = css` 17 | :host > .lnd-config { 18 | display: none; 19 | } 20 | :host.lnd-config > .configs.lnd-config { 21 | display: grid; 22 | } 23 | :host.lnd-config > .lnd-config { 24 | display: block; 25 | } 26 | 27 | :host .custom-file-input input { 28 | color: transparent; 29 | padding: 0; 30 | border: none; 31 | height: 3rem; 32 | } 33 | :host .custom-file-input input::-webkit-file-upload-button { 34 | visibility: hidden; 35 | } 36 | :host .custom-file-input input::before { 37 | content: 'Select directory'; 38 | display: block; 39 | font-family: var(--font-main); 40 | font-size: 1rem; 41 | padding: 0.5rem 1.25rem; 42 | text-align: center; 43 | letter-spacing: 0.05em; 44 | background-color: #EC375B; 45 | border-radius: 0.5rem; 46 | border: none; 47 | color: #ffffff; 48 | outline: none; 49 | transition: background-color 0.25s ease; 50 | margin: 2px 0 0 0; 51 | user-select: none; 52 | } 53 | :host .custom-file-input input:hover::before { 54 | border-color: black; 55 | } 56 | :host .custom-file-input input:active { 57 | outline: 0; 58 | } 59 | :host .custom-file-input input:active::before { 60 | background: -webkit-linear-gradient(top, #e3e3e3, #f9f9f9); 61 | } 62 | ` 63 | 64 | class SelectStreamWizard extends Component { 65 | constructor (list) { 66 | super() 67 | this.existing = [] 68 | this.select = new Select([['Create new stream', null]], { class: 'wide', border: true }) 69 | if (list) { 70 | list((err, list) => { 71 | if (err) return 72 | this.existing = list 73 | this.update() 74 | }) 75 | } 76 | } 77 | 78 | render () { 79 | const list = [['Create new stream', null]] 80 | for (const e of this.existing) { 81 | let n = e.description 82 | n += 83 | (n ? ' (' : '') + 84 | e.key.toString('hex').slice(0, 8) + 85 | '...' + 86 | e.key.toString('hex').slice(-4) + 87 | (n ? ')' : '') 88 | list.push(['Resume ' + n, e]) 89 | } 90 | const s = new Select(list, { class: 'wide', border: true }) 91 | this.select.element.replaceWith(s.element) 92 | this.select = s 93 | } 94 | 95 | validate () { 96 | return true 97 | } 98 | 99 | get value () { 100 | return this.select.value 101 | } 102 | 103 | createElement () { 104 | return html` 105 |
106 |

Select stream

107 |
108 | ${this.select.element} 109 |
110 |
111 | ` 112 | } 113 | } 114 | 115 | class PaymentWizard extends Component { 116 | constructor (s, defaultConfig) { 117 | super() 118 | const self = this 119 | this._select = s 120 | this._amount = new Input({ label: 'Amount' }) 121 | let prev = '' 122 | this._currency = new Select( 123 | [ 124 | ['Lightning Satoshis', 'lnd'], 125 | ['Free', 'free'] 126 | ], 127 | { 128 | label: 'Currency', 129 | placeholder: 'Choose one...', 130 | border: true, 131 | onchange () { 132 | self.element.classList.remove(prev + '-config') 133 | self.element.classList.add(self._currency.value + '-config') 134 | prev = self._currency.value 135 | } 136 | } 137 | ) 138 | this._perUnit = new Input({ label: 'Per time interval' }) 139 | this._timeUnit = new Select( 140 | [ 141 | ['Seconds', 'seconds'], 142 | ['Minutes', 'minutes'], 143 | ['Hours', 'hours'] 144 | ], 145 | { label: 'Unit', border: true } 146 | ) 147 | 148 | this._lightningDir = new Input({ 149 | label: 'Lightning directory', 150 | type: 'file', 151 | webkitdirectory: true, 152 | class: 'custom-file-input', 153 | onchange (e) { 154 | const dir = path.dirname(this.files[0].path) 155 | const conf = loadConfig(dir) 156 | 157 | if (conf.host) self._lightningAddress.value = conf.host 158 | if (conf.cert) self._lightningCert.value = conf.cert 159 | if (conf.macaroon) self._lightningMacaroon.value = conf.macaroon 160 | } 161 | }) 162 | 163 | this._lightningAddress = new Input({ label: 'RPC Host' }) 164 | this._lightningMacaroon = new Input({ label: 'Macaroon' }) 165 | this._lightningCert = new Input({ label: 'TLS Cert' }) 166 | 167 | const conf = defaultConfig && defaultConfig.LightningSats 168 | if (conf) { 169 | if (conf.host) self._lightningAddress.value = conf.host 170 | if (conf.cert) self._lightningCert.value = conf.cert 171 | if (conf.macaroon) self._lightningMacaroon.value = conf.macaroon 172 | } 173 | } 174 | 175 | validate () { 176 | const c = this._currency.value 177 | let valid = true 178 | 179 | notEmpty(this._currency) 180 | if (!c) return false 181 | 182 | if (c !== 'free') { 183 | notEmpty(this._amount) 184 | notEmpty(this._perUnit) 185 | } 186 | 187 | if (c === 'lnd') { 188 | notEmpty(this._lightningMacaroon) 189 | notEmpty(this._lightningAddress) 190 | notEmpty(this._lightningCert) 191 | } 192 | 193 | return valid 194 | 195 | function notEmpty (el) { 196 | if (!el.value) { 197 | el.error = true 198 | valid = false 199 | } else { 200 | el.error = false 201 | } 202 | } 203 | } 204 | 205 | get value () { 206 | const c = this._currency.value 207 | 208 | if (c === 'free') { 209 | return null 210 | } 211 | 212 | if (c !== 'lnd') throw new Error('Only LND is supported currently') 213 | 214 | return { 215 | payment: { 216 | currency: 'LightningSats', 217 | amount: this._amount.value || '0', 218 | interval: Number(this._perUnit.value) || 0, 219 | unit: this._timeUnit.value 220 | }, 221 | config: { 222 | implementation: 'lnd', 223 | cert: this._lightningCert.value, 224 | network: window.LND_NETWORK, 225 | host: this._lightningAddress.value, 226 | macaroon: this._lightningMacaroon.value 227 | } 228 | } 229 | } 230 | 231 | onload () { 232 | this.check() 233 | } 234 | 235 | check () { 236 | if (this._select.value) { 237 | const v = this._select.value 238 | const p = v.payment 239 | const config = v.config 240 | const currency = (p && (p.currency === 'LightningSats' ? 'lnd' : p.currency)) || 'free' 241 | 242 | this._amount.disabled = true 243 | this._amount.value = p ? p.amount : '' 244 | this._currency.disabled = true 245 | this._currency.value = currency 246 | this._perUnit.disabled = true 247 | this._perUnit.value = p ? p.interval : '' 248 | this._timeUnit.disabled = true 249 | this._timeUnit.value = p ? p.unit : '' 250 | 251 | if (currency === 'lnd') { 252 | this.element.classList.add('lnd-config') 253 | if (config) { 254 | this._lightningCert.value = config.cert 255 | this._lightningAddress.value = config.host 256 | this._lightningMacaroon.value = config.macaroon 257 | } 258 | } 259 | } else { 260 | this._amount.disabled = false 261 | this._currency.disabled = false 262 | this._perUnit.disabled = false 263 | this._timeUnit.disabled = false 264 | this._lightningDir.disabled = false 265 | this._lightningCert.disabled = false 266 | this._lightningAddress.disabled = false 267 | this._lightningMacaroon.disabled = false 268 | } 269 | } 270 | 271 | createElement () { 272 | process.nextTick(() => this.check()) 273 | 274 | const self = this 275 | const zap = HAS_ZAP 276 | ? html`(Use Zap.app?)` 277 | : '' 278 | 279 | return html` 280 |
281 |

Payment Options

282 |
283 | ${this._amount.element} ${this._currency.element} 284 | ${this._perUnit.element} ${this._timeUnit.element} 285 |
286 |

LND Lightning Configuration ${zap}

287 |
288 | ${this._lightningDir.element} 289 | ${this._lightningAddress.element} 290 | ${this._lightningCert.element} 291 | ${this._lightningMacaroon.element} 292 |
293 |
294 | ` 295 | function useZap (e) { 296 | e.preventDefault() 297 | const conf = loadConfig(path.join(os.homedir(), 'Library/Application Support/Zap/lnd/bitcoin', window.LND_NETWORK, 'wallet-1')) 298 | 299 | self._lightningAddress.value = conf.host || 'localhost:11009' 300 | if (conf.cert) self._lightningCert.value = conf.cert 301 | if (conf.macaroon) self._lightningMacaroon.value = conf.macaroon 302 | } 303 | } 304 | } 305 | 306 | class QualityWizard extends Component { 307 | constructor (select) { 308 | super() 309 | this._select = select 310 | this._quality = new Select( 311 | [ 312 | ['High', 2], 313 | ['Medium', 1], 314 | ['Low', 0] 315 | ], 316 | { label: 'Quality', border: true } 317 | ) 318 | this._video = new Select([], { label: 'Video device', border: true }) 319 | this._audio = new Select([], { label: 'Audio device', border: true }) 320 | this._description = new Input({ label: 'Video description' }) 321 | this.devices = [] 322 | devices((err, list) => { 323 | if (err) return console.error('device error:', err) 324 | this.devices = list 325 | this.devices.push({ kind: 'screen', label: 'Screen sharing' }) 326 | this.update() 327 | }) 328 | } 329 | 330 | render () { 331 | if (this._select.value) return 332 | 333 | const v = [] 334 | const a = [] 335 | 336 | for (const dev of this.devices) { 337 | if (dev.deviceId === 'default') continue 338 | 339 | const r = 340 | dev.kind === 'audioinput' 341 | ? a 342 | : dev.kind === 'videoinput' 343 | ? v 344 | : dev.kind === 'screen' 345 | ? v 346 | : [] 347 | 348 | r.push([dev.label, dev]) 349 | } 350 | 351 | const video = this._video 352 | const audio = this._audio 353 | 354 | this._video = new Select(v, { label: 'Video device', border: true }) 355 | this._audio = new Select(a, { label: 'Audio device', border: true }) 356 | 357 | video.element.replaceWith(this._video.element) 358 | audio.element.replaceWith(this._audio.element) 359 | 360 | this.check() 361 | } 362 | 363 | onload () { 364 | this.check() 365 | } 366 | 367 | validate () { 368 | let valid = true 369 | notEmpty(this._quality) 370 | notEmpty(this._video) 371 | notEmpty(this._audio) 372 | return valid 373 | 374 | function notEmpty (el) { 375 | if (!el.value && el.value !== 0) { 376 | el.error = true 377 | valid = false 378 | } else { 379 | el.error = false 380 | } 381 | } 382 | } 383 | 384 | get value () { 385 | return { 386 | quality: this._quality.value, 387 | video: this._video.value, 388 | audio: this._audio.value, 389 | description: this._description.value 390 | } 391 | } 392 | 393 | check () { 394 | if (this._select.value) { 395 | const v = this._select.value 396 | this._quality.value = v.quality 397 | const vi = this.devices.find(d => d.deviceId === v.video) 398 | if (vi) this._video.value = vi 399 | const ai = this.devices.find(d => d.deviceId === v.audio) 400 | if (ai) this._audio.value = ai 401 | this._description.disabled = true 402 | this._description.value = v.description || '' 403 | } else { 404 | this._quality.disabled = false 405 | this._video.disabled = false 406 | this._audio.disabled = false 407 | this._description.disabled = false 408 | } 409 | } 410 | 411 | createElement () { 412 | this.check() 413 | return html` 414 |
415 |

Stream Options

416 |
417 | ${this._quality.element} ${this._video.element} ${this._audio.element} 418 | ${this._description.element} 419 |
420 |
421 | ` 422 | } 423 | } 424 | 425 | module.exports = class BroadcastWizard extends Wizard { 426 | constructor (opts = {}) { 427 | const s = new SelectStreamWizard(opts.list) 428 | 429 | opts.list((_, elms) => { 430 | if (elms == null || elms.length === 0) this.next() 431 | }) 432 | 433 | super( 434 | [ 435 | ['Select Stream', s], 436 | ['Payment options', new PaymentWizard(s, opts.defaultConfig)], 437 | ['Stream options', new QualityWizard(s)], 438 | ['Broadcast', null] 439 | ], 440 | { 441 | title: 'Start broadcast', 442 | ...opts 443 | } 444 | ) 445 | } 446 | } 447 | 448 | function tryRead (name, enc) { 449 | try { 450 | return fs.readFileSync(name, enc) 451 | } catch (_) { 452 | return '' 453 | } 454 | } 455 | 456 | function loadConfig (dir) { 457 | let config = tryRead(path.join(dir, 'lnd.conf'), 'utf-8') 458 | if (config) config = config.split('rpclisten=')[1] 459 | if (config) config = config.split('\n')[0] 460 | if (config) config = config.trim() 461 | 462 | return { 463 | host: config || '', 464 | cert: tryRead(path.join(dir, 'tls.cert'), 'base64'), 465 | macaroon: tryRead(path.join(dir, 'data/chain/bitcoin', window.LND_NETWORK, 'admin.macaroon'), 'base64') 466 | } 467 | } 468 | -------------------------------------------------------------------------------- /global.css: -------------------------------------------------------------------------------- 1 | /* font */ 2 | 3 | @font-face { 4 | font-family: 'ibm_plex_sansregular'; 5 | src: url('./assets/ibmplexsans-regular-webfont.woff2') format('woff2'); 6 | font-weight: normal; 7 | font-style: normal; 8 | } 9 | 10 | @font-face { 11 | font-family: 'ibm_plex_sansregular'; 12 | src: url('./assets/ibmplexsans-semibold-webfont.woff2') format('woff2'); 13 | font-weight: bold; 14 | font-style: normal; 15 | } 16 | 17 | @font-face { 18 | font-family: 'ibm_plex_monoregular'; 19 | src: url('./assets/ibmplexmono-regular-webfont.woff2') format('woff2'); 20 | font-weight: normal; 21 | font-style: normal; 22 | } 23 | 24 | /* style guide variables */ 25 | 26 | :root { 27 | /* hue*/ 28 | --accentH: 355; 29 | --scrollStepOffsetH: 233; 30 | --gradientOffsetUpH: -7; 31 | --gradientOffsetDownH: -24; 32 | /* saturation*/ 33 | --accentS: 79%; 34 | --unsaturated: 0%; 35 | --logoS: 18%; 36 | --gradientOffsetUpS: 4%; 37 | --gradientOffsetDownS: 21%; 38 | /* lightness */ 39 | --accentL: 57%; 40 | --spotlight: 99%; 41 | --backlight: 7%; 42 | --logoL: 24%; 43 | --gradientOffsetUpL: 0%; 44 | --gradientOffsetDownL: 21%; 45 | /* opacity */ 46 | --alpha: 1; 47 | --opaque: 1; 48 | --invisible: 0; 49 | /* ruler */ 50 | --ruler-multiplier: 3; 51 | --one: .23em; 52 | --two: calc(var(--one) * var(--ruler-multiplier)); 53 | --three: calc(var(--two) * var(--ruler-multiplier)); 54 | --four: calc(var(--three) * var(--ruler-multiplier)); 55 | --five: calc(var(--four) * var(--ruler-multiplier)); 56 | /* timings */ 57 | --yawn: 5s; 58 | --long: 2.5s; 59 | --mid: 1.8s; 60 | --short: .3s; 61 | --blink: .15s; 62 | /* font variables */ 63 | --font-size: 16px; 64 | --font-main: 'ibm_plex_sansregular', serif; 65 | --font-support: 'ibm_plex_monoregular', serif; 66 | /**/ 67 | --logo-gradient: linear-gradient(4deg, hsl(348, 83%, 57%) 2.53%, hsl(331, 100%, 76%) 67.18%); 68 | } 69 | 70 | 71 | /* element defaults */ 72 | 73 | body { 74 | font-family: var(--font-main); 75 | font-style: normal; 76 | user-select: none; 77 | line-height: 1.5; 78 | font-size: var(--font-size); 79 | margin: 0; 80 | padding: 0; 81 | } 82 | 83 | a, 84 | a:visited, 85 | a:hover { 86 | color: hsla(var(--hue, var(--accentH)), var(--accentS), var(--lightness, var(--accentL)), var(--alpha)); 87 | text-decoration: none; 88 | } 89 | 90 | html { 91 | transition: background-color 1s ease; 92 | } 93 | 94 | svg, 95 | a { 96 | transition-property: color, fill, stroke, opacity; 97 | transition-duration: 1s; 98 | transition-timing-function: ease; 99 | } 100 | 101 | h1, 102 | h2, 103 | h3, 104 | h4, 105 | h5 {} 106 | 107 | 108 | /* animations */ 109 | 110 | @keyframes ripple { 111 | from { 112 | transform: scale(var(--pulse-from), var(--pulse-from)); 113 | opacity: var(--opaque); 114 | } 115 | to { 116 | transform: scale(var(--pulse-to), var(--pulse-to)); 117 | opacity: var(--invisible); 118 | } 119 | } 120 | 121 | @keyframes pulsate { 122 | from { 123 | transform: scale(var(--pulse-from), var(--pulse-from)); 124 | } 125 | to { 126 | transform: scale(var(--pulse-to), var(--pulse-to)); 127 | } 128 | } 129 | 130 | @keyframes fade-in { 131 | from { 132 | opacity: var(--invisible); 133 | } 134 | to { 135 | opacity: var(--opaque); 136 | } 137 | } 138 | 139 | @keyframes fade-out { 140 | from { 141 | opacity: var(--opaque); 142 | } 143 | to { 144 | opacity: var(--invisible); 145 | } 146 | } 147 | 148 | @keyframes slide-from-right { 149 | from { 150 | transform: translateX(50%); 151 | } 152 | to { 153 | transform: translateX(0); 154 | } 155 | } 156 | 157 | 158 | /* utilities */ 159 | 160 | .family-support { 161 | font-family: var(--font-support); 162 | } 163 | 164 | .family-main { 165 | font-family: var(--font-main); 166 | } 167 | 168 | .f-180 { 169 | font-size: 180%; 170 | } 171 | 172 | .f-160 { 173 | font-size: 160%; 174 | } 175 | 176 | .f-140 { 177 | font-size: 140%; 178 | } 179 | 180 | .f-70 { 181 | font-size: 70%; 182 | } 183 | 184 | .f-50 { 185 | font-size: 50%; 186 | } 187 | 188 | .normal { 189 | font-style: normal; 190 | font-weight: normal; 191 | } 192 | 193 | .bold { 194 | font-weight: 600; 195 | } 196 | 197 | .tr { 198 | text-align: right; 199 | } 200 | 201 | .lh-copy { 202 | line-height: 1.5; 203 | } 204 | 205 | .ls-big { 206 | letter-spacing: .4rem; 207 | } 208 | 209 | .uppercase { 210 | text-transform: uppercase; 211 | } 212 | 213 | .tc { 214 | text-align: center; 215 | } 216 | 217 | .tr { 218 | text-align: right; 219 | } 220 | 221 | .df { 222 | display: flex; 223 | } 224 | 225 | .db { 226 | display: block; 227 | } 228 | 229 | .dib { 230 | display: inline-block; 231 | } 232 | 233 | .dn { 234 | display: none; 235 | } 236 | 237 | .sticky { 238 | position: sticky; 239 | } 240 | 241 | .absolute { 242 | position: absolute; 243 | } 244 | 245 | .fixed { 246 | position: fixed; 247 | } 248 | 249 | .relative { 250 | position: relative; 251 | } 252 | 253 | .m-auto-h { 254 | margin-left: auto; 255 | margin-right: auto; 256 | } 257 | 258 | .m0 { 259 | margin: 0; 260 | } 261 | 262 | .mb0 { 263 | margin-bottom: 0; 264 | } 265 | 266 | .mt0 { 267 | margin-top: 0; 268 | } 269 | 270 | .m1 { 271 | margin: var(--one); 272 | } 273 | 274 | .mb1 { 275 | margin-bottom: var(--one); 276 | } 277 | 278 | .m2 { 279 | margin: var(--two); 280 | } 281 | 282 | .m3 { 283 | margin: var(--three); 284 | } 285 | 286 | .m4r { 287 | margin-right: var(--four); 288 | } 289 | 290 | .m4 { 291 | margin: var(--four); 292 | } 293 | 294 | .mb4 { 295 | margin-bottom: var(--four); 296 | } 297 | 298 | .m5 { 299 | margin: var(--five); 300 | } 301 | 302 | .ml5 { 303 | margin-left: var(--five); 304 | } 305 | 306 | .mt-20vh { 307 | margin-top: 20vh; 308 | } 309 | 310 | .mt-30vh { 311 | margin-top: 30vh; 312 | } 313 | 314 | .p2 { 315 | padding: var(--two); 316 | } 317 | 318 | .p3 { 319 | padding: var(--three); 320 | } 321 | 322 | .p4 { 323 | padding: var(--four); 324 | } 325 | 326 | .pv1 { 327 | padding-top: var(--one); 328 | padding-bottom: var(--one); 329 | } 330 | 331 | .pv2 { 332 | padding-top: var(--two); 333 | padding-bottom: var(--two); 334 | } 335 | 336 | .pv3 { 337 | padding-top: var(--three); 338 | padding-bottom: var(--three); 339 | } 340 | 341 | .p5 { 342 | padding: var(--five); 343 | } 344 | 345 | .b0 { 346 | border: 0; 347 | } 348 | 349 | .br3 { 350 | border-radius: 1.5rem; 351 | } 352 | 353 | .w-4 { 354 | width: var(--four); 355 | } 356 | 357 | .w-5 { 358 | width: var(--five); 359 | } 360 | 361 | .h-3 { 362 | height: var(--three); 363 | } 364 | 365 | .mh-mc { 366 | min-height: min-content; 367 | } 368 | 369 | .bottom-0 { 370 | bottom: 0; 371 | } 372 | 373 | .columns { 374 | flex-direction: column; 375 | } 376 | 377 | .column-width { 378 | max-width: 32ch; 379 | } 380 | 381 | .body-width { 382 | max-width: 70ch; 383 | } 384 | 385 | .flex { 386 | flex: auto; 387 | } 388 | 389 | .rows { 390 | flex-direction: row; 391 | } 392 | 393 | .justify-center { 394 | justify-content: center; 395 | } 396 | 397 | .justify-between { 398 | justify-content: space-between; 399 | } 400 | 401 | .justify-around { 402 | justify-content: space-around; 403 | } 404 | 405 | .align-center { 406 | align-items: center; 407 | } 408 | 409 | .align-end { 410 | align-items: flex-end; 411 | } 412 | 413 | .align-self-end { 414 | align-self: end; 415 | } 416 | 417 | .flex-wrap { 418 | flex-wrap: wrap; 419 | } 420 | 421 | .pulsate { 422 | animation-name: pulsate; 423 | } 424 | 425 | .ripple { 426 | animation-name: ripple; 427 | } 428 | 429 | .fade-in { 430 | animation-name: fade-in; 431 | } 432 | 433 | .fade-out { 434 | animation-name: fade-out; 435 | } 436 | 437 | .infinite { 438 | animation-iteration-count: infinite; 439 | } 440 | 441 | .a-alternate { 442 | animation-direction: alternate; 443 | } 444 | 445 | .a-duration-yawn { 446 | animation-duration: var(--yawn); 447 | } 448 | 449 | .a-duration-long { 450 | animation-duration: var(--long); 451 | } 452 | 453 | .a-duration-mid { 454 | animation-duration: var(--mid); 455 | } 456 | 457 | .a-duration-short { 458 | animation-duration: var(--short); 459 | } 460 | 461 | .a-fill-both { 462 | animation-fill-mode: both; 463 | } 464 | 465 | .a-delay-long { 466 | animation-delay: 2.5s 467 | } 468 | 469 | .a-delay { 470 | animation-delay: .8s; 471 | } 472 | 473 | .a-delay-short { 474 | animation-delay: .3s; 475 | } 476 | 477 | .a-ease-in-out { 478 | animation-timing-function: ease-in-out; 479 | } 480 | 481 | .a-linear { 482 | animation-timing-function: linear; 483 | } 484 | 485 | .t-ease { 486 | transition-timing-function: ease; 487 | } 488 | 489 | .t-origin-center { 490 | transform-origin: center; 491 | } 492 | 493 | .t-delay-micro { 494 | transition-delay: .1s 495 | } 496 | 497 | .t-delay { 498 | transition-delay: .3s; 499 | } 500 | 501 | .t-delay-short { 502 | transition-delay: .2s; 503 | } 504 | 505 | .t-duration-long { 506 | transition-duration: var(--long); 507 | } 508 | 509 | .t-duration-mid { 510 | transition-duration: var(--mid); 511 | } 512 | 513 | .t-duration-short { 514 | transition-duration: var(--short); 515 | } 516 | 517 | .tt { 518 | transition-property: transform; 519 | } 520 | 521 | .to { 522 | transition-property: --opaque; 523 | } 524 | 525 | .white { 526 | color: white; 527 | } 528 | 529 | .highlight { 530 | color: hsla(var(--hue, var(--accentH)), var(--accentS), var(--lightness, var(--accentL)), var(--alpha)); 531 | will-change: color, fill, stroke; 532 | } 533 | 534 | .bg-highlight { 535 | background-color: hsla(var(--hue, var(--accentH)), var(--accentS), var(--accentL), var(--alpha)); 536 | } 537 | 538 | .spotlight { 539 | color: hsla(var(--hue, var(--accentH)), var(--saturaion, var(--accentS)), var(--lightness, var(--spotlight)), var(--alpha)); 540 | } 541 | 542 | .bg-spotlight { 543 | background-color: hsla(var(--hue, var(--accentH)), var(--saturaion, var(--accentS)), var(--lightness, var(--spotlight)), var(--alpha)); 544 | } 545 | 546 | .backlight { 547 | color: hsla(var(--hue, var(--accentH)), var(--saturation, var(--accentS)), var(--lightness, var(--backlight)), var(--alpha)); 548 | } 549 | 550 | .bg-backlight { 551 | background-color: hsla(var(--hue, var(--accentH)), var(--saturation, var(--accentS)), var(--lightness, var(--backlight)), var(--alpha)); 552 | } 553 | 554 | .bg-fadegradient { 555 | background-image: linear-gradient(to bottom, hsla(var(--hue, var(--accentH)), var(--accentS), var(--backlight), 0) 0%, hsla(var(--hue, var(--accentH)), var(--saturation, var(--accentS)), var(--lightness, var(--backlight)), .8) 10%, hsla(var(--hue, var(--accentH)), var(--saturation, var(--accentS)), var(--lightness, var(--backlight)), .8) 90%, hsla(var(--hue, var(--accentH)), var(--accentS), var(--backlight), 0) 100%); 556 | } 557 | 558 | .ts-backlight { 559 | text-shadow: 0 0 2rem hsla(var(--hue, var(--accentH)), var(--saturation, var(--accentS)), var(--lightness, var(--backlight)), var(--alpha)), 0 0 2rem hsla(var(--hue, var(--accentH)), var(--saturation, var(--accentS)), var(--lightness, var(--backlight)), var(--alpha)), 0 0 2rem hsla(var(--hue, var(--accentH)), var(--saturation, var(--accentS)), var(--lightness, var(--backlight)), var(--alpha)); 560 | } 561 | 562 | .bg-spotgradient { 563 | background-image: linear-gradient(to bottom, hsl(calc(var(--hue, var(--accentH)) + var(--gradientOffsetDownH)), calc(var(--accentS) + var(--gradientOffsetDownS)), calc(var(--accentL) + var(--gradientOffsetDownL))) 3%, hsl(calc(var(--hue, var(--accentH)) + var(--gradientOffsetUpH)), calc(var(--accentS) + var(--gradientOffsetUpS)), calc(var(--accentL) + var(--gradientOffsetUpL))) 67%); 564 | } 565 | 566 | .hover-highlight:hover { 567 | color: hsla(var(--hue, var(--accentH)), var(--accentS), var(--lightness, var(--accentL)), var(--alpha)); 568 | } 569 | 570 | .z0 { 571 | z-index: 0; 572 | } 573 | 574 | .z1 { 575 | z-index: 1; 576 | } 577 | 578 | .z2 { 579 | z-index: 2; 580 | } 581 | 582 | .left-0 { 583 | left: 0; 584 | } 585 | 586 | .right-0 { 587 | right: 0; 588 | } 589 | 590 | .top-0 { 591 | top: 0; 592 | } 593 | 594 | .bottom-0 { 595 | bottom: 0; 596 | } 597 | 598 | .round { 599 | border-radius: 50%; 600 | } 601 | 602 | .w-0 { 603 | width: 0; 604 | } 605 | 606 | .h-0 { 607 | height: 0; 608 | } 609 | 610 | .w-vm-100 { 611 | width: 100vmin; 612 | } 613 | 614 | .h-vm-100 { 615 | height: 100vmin; 616 | } 617 | 618 | .h-vm-60 { 619 | height: 60vmin 620 | } 621 | 622 | .minh-vh-100 { 623 | min-height: 100vh; 624 | } 625 | 626 | .maxh-vh-100 { 627 | max-height: 100vh; 628 | } 629 | 630 | .h-100 { 631 | height: 100%; 632 | } 633 | 634 | .maxw-mc { 635 | max-width: min-content; 636 | } 637 | 638 | .ss-start { 639 | scroll-snap-align: start start; 640 | } 641 | 642 | .ss-center { 643 | scroll-snap-align: center center; 644 | } 645 | 646 | .ss-end { 647 | scroll-snap-align: end end; 648 | } 649 | 650 | .ssp4 { 651 | scroll-snap-padding: var(--three); 652 | } 653 | 654 | .ssx-mandatory { 655 | scroll-snap-type: y mandatory; 656 | } 657 | 658 | .mbm-multiply { 659 | mix-blend-mode: multiply; 660 | } 661 | 662 | .unselectable { 663 | user-select: none; 664 | } 665 | 666 | .o-0 { 667 | opacity: var(--invisible); 668 | } 669 | 670 | .o-5 { 671 | opacity: .5; 672 | } 673 | 674 | .o-3 { 675 | opacity: .3; 676 | } 677 | 678 | .invisible { 679 | visibility: hidden; 680 | } 681 | 682 | .overflow-x-hidden { 683 | overflow-x: hidden; 684 | } 685 | 686 | .overflow-y-auto { 687 | overflow-y: auto; 688 | } 689 | 690 | .overflow-visible { 691 | overflow: visible; 692 | } 693 | 694 | .indent:before { 695 | content: '> '; 696 | } 697 | 698 | .border-box { 699 | box-sizing: border-box; 700 | } 701 | 702 | .whitespace-nowrap { 703 | white-space: nowrap; 704 | } 705 | 706 | .clip { 707 | clip-path: inset(50%); 708 | } 709 | 710 | .pointer { 711 | cursor: pointer; 712 | } 713 | 714 | .pre-line:before { 715 | content: ' '; 716 | display: block; 717 | height: 8rem; 718 | border-left: 2px solid currentColor; 719 | } 720 | 721 | 722 | /* tricks */ 723 | 724 | @media (prefers-color-scheme: light) { 725 | :root { 726 | --spotlight: 3%; 727 | --backlight: 98%; 728 | } 729 | } 730 | 731 | main section.scrolled:first-child:after { 732 | animation: fade-out .4s ease forwards; 733 | } 734 | 735 | 736 | /* TODO: make this an actual element with classes? */ 737 | 738 | main .downarrow:after { 739 | content: ' '; 740 | transform: rotate(45deg); 741 | display: block; 742 | min-width: 2rem; 743 | min-height: 2rem; 744 | border-width: 0 .2rem .2rem 0; 745 | border-style: solid; 746 | margin-bottom: 2rem; 747 | } 748 | 749 | 750 | /* responsive utilities */ 751 | 752 | @media (orientation: portrait) { 753 | .p-columns { 754 | flex-direction: column; 755 | } 756 | .p-rows { 757 | flex-direction: row; 758 | } 759 | .p-justify-flex-end { 760 | justify-content: flex-end; 761 | } 762 | } 763 | 764 | @media (orientation: landscape) { 765 | .l-align-self-end { 766 | align-self: flex-end; 767 | } 768 | .l-move-left { 769 | transform: translateX(-40vmin); 770 | } 771 | .l-ssx-proximity { 772 | scroll-snap-type: y proximity; 773 | } 774 | } 775 | --------------------------------------------------------------------------------