├── .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 |
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 |
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 |
195 |
DAZAAR
196 |
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 |
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 |
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 |
46 | `
47 |
48 | const PAUSE = `
49 |
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 |
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 |
--------------------------------------------------------------------------------