Finished implementing basic streamer UI and controls
This commit is contained in:
parent
c1fcd3a47d
commit
9d71c3b0ea
31 changed files with 1641 additions and 57 deletions
59
.gitea/workflows/deploy.yaml
Normal file
59
.gitea/workflows/deploy.yaml
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: alpine
|
||||||
|
steps:
|
||||||
|
- name: Check out repository code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
- name: Check for new release
|
||||||
|
run: |
|
||||||
|
chmod +x ./7zas
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "------------------------------------"
|
||||||
|
echo ""
|
||||||
|
echo "checking $f";
|
||||||
|
cd $f
|
||||||
|
|
||||||
|
CURR_VER="$(cat package.json | jq -r .name)_v$(cat package.json | jq -r .version)"
|
||||||
|
CURR_NAME="$(cat package.json | jq -r .name) v$(cat package.json | jq -r .version)"
|
||||||
|
|
||||||
|
echo "Checking https://git.nfp.is/api/v1/repos/${{ gitea.repository }}/releases for name ${CURR_NAME}"
|
||||||
|
|
||||||
|
if curl -s -X GET -H "Authorization: token ${{ secrets.deploytoken }}" https://git.nfp.is/api/v1/repos/${{ gitea.repository }}/releases | grep -o "\"name\"\:\"${CURR_NAME}\"" > /dev/null; then
|
||||||
|
echo "Skipping ${{ gitea.job }} since $CURR_NAME already exists";
|
||||||
|
cd ..
|
||||||
|
continue;
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "New release ${CURR_VER} found, running npm install..."
|
||||||
|
|
||||||
|
mv package.json fuck-you-npm-package.json
|
||||||
|
mv build-package.json package.json
|
||||||
|
npm install && npm run build
|
||||||
|
|
||||||
|
mv package.json build-package.json
|
||||||
|
mv fuck-you-npm-package.json package.json
|
||||||
|
|
||||||
|
../7zas a -xr!*.xcf -mx9 "${CURR_VER}_build-sc.7z" package.json index.mjs api public
|
||||||
|
|
||||||
|
echo "Creating ${CURR_VER} release on gitea"
|
||||||
|
RELEASE_RESULT=$(curl \
|
||||||
|
-X POST \
|
||||||
|
-H "Authorization: token ${{ secrets.deploytoken }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
https://git.nfp.is/api/v1/repos/${{ gitea.repository }}/releases \
|
||||||
|
-d "{\"tag_name\":\"${CURR_VER}\",\"name\":\"${CURR_NAME}\",\"body\":\"Automatic release from Appveyor from ${{ gitea.sha }} :\n\n${{ gitea.event.head_commit.message }}\"}")
|
||||||
|
|
||||||
|
RELEASE_ID=$(echo $RELEASE_RESULT | jq -r .id)
|
||||||
|
echo "Adding ${CURR_VER}_build-sc.7z to release ${RELEASE_ID}"
|
||||||
|
|
||||||
|
curl \
|
||||||
|
-X POST \
|
||||||
|
-H "Authorization: token ${{ secrets.deploytoken }}" \
|
||||||
|
-F "attachment=@${CURR_VER}_build-sc.7z" \
|
||||||
|
https://git.nfp.is/api/v1/repos/${{ gitea.repository }}/releases/$RELEASE_ID/assets
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -61,4 +61,5 @@ typings/
|
||||||
config.json
|
config.json
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
|
||||||
|
db.json
|
||||||
public/assets/app.js
|
public/assets/app.js
|
||||||
|
|
BIN
7zas
Normal file
BIN
7zas
Normal file
Binary file not shown.
295
api/encoder/encoder.mjs
Normal file
295
api/encoder/encoder.mjs
Normal file
|
@ -0,0 +1,295 @@
|
||||||
|
import { effect, signal, batch } from '@preact/signals-core'
|
||||||
|
import { runCommand } from './runner.mjs'
|
||||||
|
import serial from '../serial/serial.mjs'
|
||||||
|
|
||||||
|
class EncoderManager {
|
||||||
|
constructor() {
|
||||||
|
this.encoder = signal(null)
|
||||||
|
this.frameMatcher = new RegExp('frame= *(\\d+\\.?\\d*).*fps= *(\\d+\\.?\\d*).*bitrate= *(\\d+\\.?\\d*).*speed= *(\\d+\\.?\\d*)')
|
||||||
|
this.repeatedMatcher = new RegExp('repeated (\\d+) time')
|
||||||
|
|
||||||
|
this.stopError = null
|
||||||
|
this.attempting = signal(false)
|
||||||
|
this.encoderStatus = signal(null)
|
||||||
|
this.data = ''
|
||||||
|
this.failues = 0
|
||||||
|
this.logHistory = []
|
||||||
|
this.setup()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSmartMonitor(status) {
|
||||||
|
if (status.errors) {
|
||||||
|
if ((new Date() - status.lastError) > 1000 * 60 * 60) {
|
||||||
|
status.errors = status.repeated = 0
|
||||||
|
status.lastError = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (status.speed < 1) {
|
||||||
|
status.showSlowSpeed = new Date()
|
||||||
|
} else if (status.speed > 1 || (status.showSlowSpeed && new Date() - status.showSlowSpeed > 1000 * 60 * 60 * 2)) {
|
||||||
|
status.showSlowSpeed = null
|
||||||
|
}
|
||||||
|
if (status.errors && status.errors > 100) {
|
||||||
|
this.stopError = new Error('Too many errors')
|
||||||
|
this.safeStop()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
effect(() => {
|
||||||
|
let attempting = this.attempting.value
|
||||||
|
let status = this.encoderStatus.value
|
||||||
|
let encoder = this.encoder.value
|
||||||
|
|
||||||
|
if (this.stopError) {
|
||||||
|
this.io?.io.emit('encoder.stats', null)
|
||||||
|
this.io?.io.emit('encoder.status', this.status())
|
||||||
|
return serial.updateDisplay(
|
||||||
|
'Stream was stopped:',
|
||||||
|
this.stopError.message,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (attempting || (encoder && !status)) {
|
||||||
|
this.io?.io.emit('encoder.status', this.status())
|
||||||
|
return serial.updateDisplay(
|
||||||
|
'Filadelfia streamer',
|
||||||
|
'Starting [ ]',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!encoder && status) {
|
||||||
|
this.io?.io.emit('encoder.stats', null)
|
||||||
|
this.io?.io.emit('encoder.status', this.status())
|
||||||
|
return serial.updateDisplay(
|
||||||
|
'Error, no encoder',
|
||||||
|
'but there was status',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!encoder) {
|
||||||
|
this.io?.io.emit('encoder.stats', null)
|
||||||
|
this.io?.io.emit('encoder.status', this.status())
|
||||||
|
return serial.updateDisplay(
|
||||||
|
'Filadelfia streamer',
|
||||||
|
'Status: Offline.',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (this.updateSmartMonitor(status)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (status.lastPrint && (new Date() - status.lastPrint) < 1000) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.io?.io.emit('encoder.stats', status)
|
||||||
|
status.lastPrint = new Date()
|
||||||
|
let prefix = 'LIVE'
|
||||||
|
if (status.showSlowSpeed) {
|
||||||
|
prefix = 'SLOW SPEED'
|
||||||
|
}
|
||||||
|
if (status.errors) {
|
||||||
|
prefix = `${status.errors} ERRORS`
|
||||||
|
}
|
||||||
|
let fps = `fps=${status.fps.toFixed(1)}`
|
||||||
|
return serial.updateDisplay(
|
||||||
|
prefix + (` spd=${status.speed % 1 === 0 ? status.speed : status.speed.toFixed(3)}x`).padStart(20 - prefix.length),
|
||||||
|
fps + (` kbps=${Math.round(status.bitrate)}`.padStart(20 - fps.length)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
init(core, io) {
|
||||||
|
this.core = core
|
||||||
|
this.io = io
|
||||||
|
if (!this.core.db.data.encoder_settings) {
|
||||||
|
this.core.db.data.encoder_settings = {
|
||||||
|
device: '',
|
||||||
|
format_code: '',
|
||||||
|
command: 'ffmpeg.exe -f decklink -pix_fmt yuv420p -vcodec libx264 -vf yadif=1 -b:v 8000k -maxrate 8000k -minrate 8000k -bufsize 8000k -x264-params "nal-hrd=cbr" -preset faster -tune zerolatency -g 100 -keyint_min 100 -force_key_frames "expr:gte(t,n_forced*2)" -profile:v main -r 50 -ac 2 -c:a libfdk_aac -b:a 164k -ar 44100 -f mp4 test.mp4'
|
||||||
|
}
|
||||||
|
this.core.db.write()
|
||||||
|
}
|
||||||
|
this.safeStart()
|
||||||
|
}
|
||||||
|
|
||||||
|
log(level, message, sendToLogger = true) {
|
||||||
|
this.logHistory.unshift(`[${(new Date()).toISOString().replace('T', ' ').split('.')[0]}] ${message}`)
|
||||||
|
this.logHistory = this.logHistory.slice(0, 40)
|
||||||
|
if (sendToLogger) {
|
||||||
|
this.core.log[level]('ENCODER: ' + message)
|
||||||
|
}
|
||||||
|
this.io.io.emit('encoder.status', this.status())
|
||||||
|
}
|
||||||
|
|
||||||
|
safeStart() {
|
||||||
|
this.stopError = null
|
||||||
|
if (this.attempting.value) return
|
||||||
|
|
||||||
|
batch(() => {
|
||||||
|
this.encoderStatus.value = null
|
||||||
|
this.attempting.value = true
|
||||||
|
})
|
||||||
|
|
||||||
|
this.start()
|
||||||
|
.then(
|
||||||
|
encoder => {
|
||||||
|
this.encoder.value = encoder
|
||||||
|
},
|
||||||
|
err => {
|
||||||
|
this.log('error', err.message)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
this.attempting.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
safeStop(process) {
|
||||||
|
let encoder = process || this.encoder.value || this.attempting.value
|
||||||
|
if (!encoder || encoder === true) return
|
||||||
|
try {
|
||||||
|
encoder.stdin.write('q')
|
||||||
|
} catch {
|
||||||
|
encoder.kill()
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (encoder.exitCode === null) {
|
||||||
|
encoder.kill()
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
let settings = this.core.db.data.encoder_settings
|
||||||
|
let command = settings.command
|
||||||
|
if (settings.command.indexOf('-f decklink')) {
|
||||||
|
if ((settings.command.indexOf('-i ') < 0 && !settings.device)
|
||||||
|
|| (settings.command.indexOf('-format_code') < 0 && !settings.format_code)) {
|
||||||
|
throw new Error('Missing deckling device or format_code')
|
||||||
|
}
|
||||||
|
command = command.replace('-f decklink ', `${settings.format_code} -f decklink ${settings.device} `)
|
||||||
|
}
|
||||||
|
|
||||||
|
let process = await runCommand('', command, this.fromEncoder.bind(this), true)
|
||||||
|
this.attempting.value = process
|
||||||
|
let index = 0
|
||||||
|
while (index < 100 && process.exitCode === null && !process.killed && !this.encoderStatus.value) {
|
||||||
|
if (index > 0 && index % 10 === 0) {
|
||||||
|
let bars = Buffer.from(new Array(Math.min(index / 10, 9)).fill(255))
|
||||||
|
serial.updateDisplay(
|
||||||
|
'Filadelfia streamer',
|
||||||
|
Buffer.concat([
|
||||||
|
Buffer.from('Starting ['),
|
||||||
|
bars,
|
||||||
|
Buffer.from(']'.padStart(10 - bars.length))
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
await new Promise(res => setTimeout(res, 100))
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
if (process.exitCode !== null) {
|
||||||
|
throw new Error('Failed to start encoder, exit code: ' + process.exitCode)
|
||||||
|
}
|
||||||
|
if (!this.encoderStatus.value) {
|
||||||
|
this.safeStop(process)
|
||||||
|
throw new Error('Encoder did not start successfully, shutting down')
|
||||||
|
}
|
||||||
|
this.listen(process)
|
||||||
|
|
||||||
|
return process
|
||||||
|
}
|
||||||
|
|
||||||
|
fromEncoder(message, source) {
|
||||||
|
let lowered = message.toLocaleLowerCase()
|
||||||
|
let status = this.encoderStatus.value
|
||||||
|
|
||||||
|
// Limit normal logging to log file a little bit from the encoder, just to not cause a massive spam in our log file
|
||||||
|
let allowNormalLogging = !status || !status.lastLog
|
||||||
|
let allowWarnLogging = allowNormalLogging
|
||||||
|
if (status?.lastLog && new Date() - status.lastLog > 1000 * 57) {
|
||||||
|
allowWarnLogging = true
|
||||||
|
if (new Date() - status.lastLog > 1000 * 60) {
|
||||||
|
allowNormalLogging = true
|
||||||
|
status.lastLog = new Date()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle buffered overrun detection
|
||||||
|
if (lowered.indexOf('decklink') > 0 && lowered.indexOf('buffer overrun') > 0 && status) {
|
||||||
|
status.errors += 1
|
||||||
|
status.lastError = new Date()
|
||||||
|
this.encoderStatus.value = status
|
||||||
|
return this.log('warn', message, allowWarnLogging)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle repeated error detection
|
||||||
|
let repeated = this.repeatedMatcher.exec(lowered)
|
||||||
|
if (repeated && status) {
|
||||||
|
let newRepeated = Number(repeated[1])
|
||||||
|
if (status.repeated > newRepeated) {
|
||||||
|
status.errors += newRepeated
|
||||||
|
} else {
|
||||||
|
status.errors += (newRepeated - status.repeated)
|
||||||
|
}
|
||||||
|
status.repeated = newRepeated
|
||||||
|
return this.log('warn', message, allowWarnLogging)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle anything that is not a frame progress
|
||||||
|
if (message.indexOf('frame=') < 0) {
|
||||||
|
if (status) {
|
||||||
|
status.errors += 1
|
||||||
|
status.lastError = new Date()
|
||||||
|
}
|
||||||
|
return this.log('info', message, allowNormalLogging)
|
||||||
|
}
|
||||||
|
let groups = this.frameMatcher.exec(lowered)
|
||||||
|
if (!groups) {
|
||||||
|
return this.log('warn', message)
|
||||||
|
}
|
||||||
|
status = status || {
|
||||||
|
errors: 0,
|
||||||
|
repeated: 0,
|
||||||
|
lastError: null,
|
||||||
|
lastLog: new Date()
|
||||||
|
}
|
||||||
|
this.encoderStatus.value = {
|
||||||
|
...status,
|
||||||
|
frame: Number(groups[1]),
|
||||||
|
fps: Number(groups[2]),
|
||||||
|
bitrate: Number(groups[3]),
|
||||||
|
speed: Number(groups[4]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listen(process) {
|
||||||
|
process.on('error', function(err) {
|
||||||
|
this.log('error', 'Encode process encountered an error: ' + err.message)
|
||||||
|
})
|
||||||
|
process.once('exit', (code) => {
|
||||||
|
if (code !== 0) {
|
||||||
|
this.log('error', 'Encoder returned error code: ' + code)
|
||||||
|
} else {
|
||||||
|
this.log('warn', 'Encoder closed normally')
|
||||||
|
}
|
||||||
|
batch(() => {
|
||||||
|
this.encoder.value = this.encoderStatus.value = null
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
status() {
|
||||||
|
return {
|
||||||
|
status: this.encoderStatus.value,
|
||||||
|
stopError: this.stopError?.message,
|
||||||
|
starting: Boolean(this.attempting.value),
|
||||||
|
running: Boolean(this.encoder.value),
|
||||||
|
log: this.logHistory.join('\n'),
|
||||||
|
settings: this.core.db.data.encoder_settings,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const encoder = new EncoderManager()
|
||||||
|
export default encoder;
|
|
@ -1,14 +1,52 @@
|
||||||
|
import encoder from './encoder.mjs'
|
||||||
|
import { runCommand } from './runner.mjs'
|
||||||
|
|
||||||
export default class EncoderRoutes {
|
export default class EncoderRoutes {
|
||||||
constructor(opts = {}) {
|
constructor() {
|
||||||
Object.assign(this, opts)
|
this.core = null
|
||||||
|
}
|
||||||
|
|
||||||
|
registerGlobalIo(io, server) {
|
||||||
|
this.core = server.core
|
||||||
|
encoder.init(server.core, io)
|
||||||
}
|
}
|
||||||
|
|
||||||
registerIo(server, ctx) {
|
registerIo(server, ctx) {
|
||||||
ctx.socket.safeOn('encoder.status', this.status.bind(this))
|
ctx.socket.safeOn('encoder.status', this.status.bind(this))
|
||||||
|
ctx.socket.safeOn('encoder.stop', this.stop.bind(this))
|
||||||
|
ctx.socket.safeOn('encoder.start', this.start.bind(this))
|
||||||
|
ctx.socket.safeOn('encoder.run', this.run.bind(this))
|
||||||
|
ctx.socket.safeOn('encoder.settings', this.settings.bind(this))
|
||||||
}
|
}
|
||||||
|
|
||||||
status(ctx) {
|
status(ctx) {
|
||||||
ctx.socket.emit('encoder.status', { running: false })
|
ctx.socket.emit('encoder.status', encoder.status())
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(ctx) {
|
||||||
|
encoder.safeStop()
|
||||||
|
}
|
||||||
|
|
||||||
|
start(ctx) {
|
||||||
|
encoder.safeStart()
|
||||||
|
}
|
||||||
|
|
||||||
|
settings(ctx, data) {
|
||||||
|
this.core.db.data.encoder_settings = {
|
||||||
|
device: data.device,
|
||||||
|
format_code: data.format_code,
|
||||||
|
command: data.command,
|
||||||
|
}
|
||||||
|
this.core.db.write()
|
||||||
|
}
|
||||||
|
|
||||||
|
run(ctx, data) {
|
||||||
|
return runCommand(data.command, data.arguments, (msg, source) => {
|
||||||
|
encoder.log('info', msg.replace(/\n/g, ''), 'RUN')
|
||||||
|
})
|
||||||
|
.then(
|
||||||
|
code => encoder.log('info', 'Program returned successfully', 'RUN'),
|
||||||
|
err => encoder.log('error', err.message, 'RUN')
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
49
api/encoder/runner.mjs
Normal file
49
api/encoder/runner.mjs
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
import { spawn } from 'child_process'
|
||||||
|
|
||||||
|
const argumentSplitter = new RegExp('("[^"]+")|([^ ]+)', 'g')
|
||||||
|
|
||||||
|
export function splitArguments(args) {
|
||||||
|
return Array.from(args.matchAll(argumentSplitter)).map(x => x[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendToStream(stream, source) {
|
||||||
|
return function(data) {
|
||||||
|
let cleaned = data.toString().replace(/\r\n/g, '\n').split('\n')
|
||||||
|
cleaned = cleaned.filter(x => Boolean(x.trim()))
|
||||||
|
for (let line of cleaned) {
|
||||||
|
stream(line, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runCommand(command, arg, stream = () => {}, returnProcess = false) {
|
||||||
|
let baseError = new Error('')
|
||||||
|
let options = splitArguments(arg)
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
command = options[0]
|
||||||
|
options = options.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise(function(res, rej) {
|
||||||
|
stream(`> ${command} ${options.join(' ')}\n`)
|
||||||
|
let processor = spawn(command, options, { shell: true })
|
||||||
|
|
||||||
|
processor.stdout.on('data', sendToStream(stream, 'stdout'))
|
||||||
|
processor.stderr.on('data', sendToStream(stream, 'stderr'))
|
||||||
|
processor.on('error', function(err) {
|
||||||
|
baseError.message = err.message
|
||||||
|
if (!returnProcess) rej(baseError)
|
||||||
|
})
|
||||||
|
processor.on('exit', function (code) {
|
||||||
|
if (code !== 0) {
|
||||||
|
baseError.message = 'Program returned error code: ' + code
|
||||||
|
if (!returnProcess) return rej(baseError)
|
||||||
|
}
|
||||||
|
if (!returnProcess) res(code)
|
||||||
|
})
|
||||||
|
if (returnProcess) {
|
||||||
|
res(processor)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
28
api/health/routes.mjs
Normal file
28
api/health/routes.mjs
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import { effect } from '@preact/signals-core'
|
||||||
|
import serial from '../serial/serial.mjs'
|
||||||
|
import encoder from '../encoder/encoder.mjs'
|
||||||
|
|
||||||
|
export default class HealthRoutes {
|
||||||
|
registerGlobalIo(io, server) {
|
||||||
|
effect(() => {
|
||||||
|
io.io.emit('status', this.getStatus())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
registerIo(server, ctx) {
|
||||||
|
ctx.socket.safeOn('status', this.status.bind(this))
|
||||||
|
}
|
||||||
|
|
||||||
|
status(ctx) {
|
||||||
|
ctx.socket.emit('status', this.getStatus())
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus() {
|
||||||
|
return {
|
||||||
|
serial_running: Boolean(serial.serial.value),
|
||||||
|
serial_display: serial.currentDisplay.value,
|
||||||
|
encoder_running: Boolean(encoder.encoder.value),
|
||||||
|
encoder_starting: Boolean(encoder.attempting.value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
13
api/io.mjs
13
api/io.mjs
|
@ -7,9 +7,10 @@ export default class SocketServer {
|
||||||
this.routes = routes
|
this.routes = routes
|
||||||
}
|
}
|
||||||
|
|
||||||
init(server) {
|
init(server, httpServer) {
|
||||||
this.io = new socket(server)
|
this.io = new socket(httpServer)
|
||||||
this.io.on('connection', this.onNewConnection.bind(this))
|
this.io.on('connection', this.onNewConnection.bind(this))
|
||||||
|
this.register(server, 'registerGlobalIo')
|
||||||
}
|
}
|
||||||
|
|
||||||
onNewConnection(socket) {
|
onNewConnection(socket) {
|
||||||
|
@ -24,16 +25,16 @@ export default class SocketServer {
|
||||||
|
|
||||||
ctx.socket.safeOn = this.socketSafeOn.bind(this, ctx)
|
ctx.socket.safeOn = this.socketSafeOn.bind(this, ctx)
|
||||||
|
|
||||||
this.register(ctx)
|
this.register(ctx, 'registerIo')
|
||||||
|
|
||||||
ctx.socket.emit('data', { bla: 'test' })
|
ctx.socket.emit('data', { bla: 'test' })
|
||||||
}
|
}
|
||||||
|
|
||||||
register(ctx) {
|
register(ctx, ioKey) {
|
||||||
let keys = Object.keys(this.routes)
|
let keys = Object.keys(this.routes)
|
||||||
for (let key of keys) {
|
for (let key of keys) {
|
||||||
if (this.routes[key].registerIo) {
|
if (this.routes[key][ioKey]) {
|
||||||
this.routes[key].registerIo(this, ctx)
|
this.routes[key][ioKey](this, ctx)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
25
api/serial/routes.mjs
Normal file
25
api/serial/routes.mjs
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import serial from './serial.mjs'
|
||||||
|
|
||||||
|
export default class SerialRoutes {
|
||||||
|
registerGlobalIo(io, server) {
|
||||||
|
serial.init(server.core, io)
|
||||||
|
}
|
||||||
|
|
||||||
|
registerIo(server, ctx) {
|
||||||
|
ctx.socket.safeOn('serial.status', this.status.bind(this))
|
||||||
|
ctx.socket.safeOn('serial.restart', this.restart.bind(this))
|
||||||
|
ctx.socket.safeOn('serial.display', this.display.bind(this))
|
||||||
|
}
|
||||||
|
|
||||||
|
status(ctx) {
|
||||||
|
ctx.socket.emit('serial.status', serial.status())
|
||||||
|
}
|
||||||
|
|
||||||
|
display(ctx) {
|
||||||
|
ctx.socket.emit('serial.display', serial.getDisplay())
|
||||||
|
}
|
||||||
|
|
||||||
|
restart(ctx) {
|
||||||
|
serial.serial.value?.close()
|
||||||
|
}
|
||||||
|
}
|
218
api/serial/serial.mjs
Normal file
218
api/serial/serial.mjs
Normal file
|
@ -0,0 +1,218 @@
|
||||||
|
import { SerialPort } from 'serialport'
|
||||||
|
import { autoDetect } from '@serialport/bindings-cpp'
|
||||||
|
import { effect, signal } from '@preact/signals-core'
|
||||||
|
|
||||||
|
class SerialManager {
|
||||||
|
constructor() {
|
||||||
|
this.serial = signal(null)
|
||||||
|
this.currentDisplay = signal({
|
||||||
|
first: 'Filadelfia streamer ',
|
||||||
|
second: ' ',
|
||||||
|
})
|
||||||
|
this.queue = null
|
||||||
|
this.attempting = null
|
||||||
|
this.data = ''
|
||||||
|
this.failues = 0
|
||||||
|
// this.serial = null
|
||||||
|
this.gotPing = false
|
||||||
|
this.logHistory = []
|
||||||
|
this.setup()
|
||||||
|
}
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
this.binding = autoDetect()
|
||||||
|
setInterval(() => {
|
||||||
|
if (!this.serial.value) return
|
||||||
|
this.serial.value.write('~')
|
||||||
|
}, 15000)
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
if (!this.serial.value) {
|
||||||
|
this.gotPing = false
|
||||||
|
this.displayUpdated = false
|
||||||
|
this.safeStartDelay(5)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log('info', `Connection with display established.`)
|
||||||
|
this.updateDisplay(
|
||||||
|
'Filadelfia streamer',
|
||||||
|
'Version ' + this.core.version,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
|
||||||
|
if (this.queue) {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.updateDisplay(
|
||||||
|
this.queue[0],
|
||||||
|
this.queue[1],
|
||||||
|
)
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
let display = this.currentDisplay.value
|
||||||
|
this.io?.io.emit('serial.display', display)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
init(core, io) {
|
||||||
|
this.core = core
|
||||||
|
this.io = io
|
||||||
|
this.safeStartDelay(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
log(level, message) {
|
||||||
|
this.logHistory.unshift(`[${(new Date()).toISOString().replace('T', ' ').split('.')[0]}] ${message}`)
|
||||||
|
this.logHistory = this.logHistory.slice(0, 40)
|
||||||
|
this.core.log[level]('DISPLAY: ' + message)
|
||||||
|
this.io.io.emit('serial.status', this.status())
|
||||||
|
}
|
||||||
|
|
||||||
|
bufferToString(buf) {
|
||||||
|
if (typeof buf === 'string') return buf
|
||||||
|
let out = Buffer.from(buf)
|
||||||
|
for (let i = 0; i < out.length; i++) {
|
||||||
|
if (out[i] === 255) {
|
||||||
|
out.writeUInt8('#'.charCodeAt(0), i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.toString().replace(/#/g, '█')
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDisplay(firstLine, secondLine, local = false) {
|
||||||
|
if (!local) {
|
||||||
|
this.queue = [firstLine, secondLine]
|
||||||
|
}
|
||||||
|
if (!this.serial.value) return
|
||||||
|
let first = Buffer.from(firstLine)
|
||||||
|
let second = Buffer.from(secondLine)
|
||||||
|
if (first.length > 20) {
|
||||||
|
first = first.subarray(0, 20)
|
||||||
|
} else if (first.length < 20) {
|
||||||
|
first = Buffer.concat([first, Buffer.from(new Array(20 - first.length).fill(32))])
|
||||||
|
}
|
||||||
|
if (second.length > 20) {
|
||||||
|
second = second.subarray(0, 20)
|
||||||
|
} else if (second.length < 20) {
|
||||||
|
second = Buffer.concat([second, Buffer.from(new Array(20 - second.length).fill(32))])
|
||||||
|
}
|
||||||
|
|
||||||
|
this.serial.value.write(Buffer.concat([
|
||||||
|
Buffer.from('^'),
|
||||||
|
first,
|
||||||
|
second,
|
||||||
|
]))
|
||||||
|
this.currentDisplay.value = {
|
||||||
|
first: this.bufferToString(firstLine).slice(0, 20),
|
||||||
|
second: this.bufferToString(secondLine).slice(0, 20),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
safeStartDelay(sec) {
|
||||||
|
if (this.attempting || !this.core) return
|
||||||
|
|
||||||
|
this.log('info', `Attempting to connect with display in ${sec} second${sec > 1 ? 's': ''}.`)
|
||||||
|
this.attempting = setTimeout(this.safeStart.bind(this), sec * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
safeStart() {
|
||||||
|
this.start()
|
||||||
|
.then(
|
||||||
|
serial => {
|
||||||
|
this.attempting = null
|
||||||
|
this.failues = 0
|
||||||
|
this.serial.value = serial
|
||||||
|
},
|
||||||
|
err => {
|
||||||
|
this.attempting = null
|
||||||
|
this.failues = Math.min(this.failues + 1, 4)
|
||||||
|
this.log('error', err.message)
|
||||||
|
this.safeStartDelay(this.failues * 15)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
let list = await this.binding.list()
|
||||||
|
if (!list.length) {
|
||||||
|
throw new Error('No com ports were found')
|
||||||
|
}
|
||||||
|
for (let item of list) {
|
||||||
|
this.log('info', `Found ${item.path} (${item.friendlyName})`)
|
||||||
|
}
|
||||||
|
for (let item of list) {
|
||||||
|
this.log('info', `Testing out ${item.path} (${item.friendlyName})`)
|
||||||
|
let serial = new SerialPort({
|
||||||
|
path: item.path,
|
||||||
|
baudRate: 9600,
|
||||||
|
autoOpen: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
await this.openSerial(serial)
|
||||||
|
if (!serial.port) continue
|
||||||
|
|
||||||
|
this.listen(serial)
|
||||||
|
let index = 0
|
||||||
|
while (index < 50 && !this.gotPing) {
|
||||||
|
if (index % 10 === 0) serial.write('~')
|
||||||
|
await new Promise(res => setTimeout(res, 100))
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
if (this.gotPing) return serial
|
||||||
|
serial.destroy()
|
||||||
|
}
|
||||||
|
throw new Error('Failed to find display device')
|
||||||
|
}
|
||||||
|
|
||||||
|
listen(serial) {
|
||||||
|
serial.on('data', this.onData.bind(this))
|
||||||
|
serial.on('error', err => {
|
||||||
|
this.log('error', 'serial on error: ' + err.message)
|
||||||
|
})
|
||||||
|
serial.once('close', () => {
|
||||||
|
serial.destroy()
|
||||||
|
if (serial === this.serial.value) {
|
||||||
|
this.log('warn', 'Serial was closed')
|
||||||
|
this.serial.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onData(data) {
|
||||||
|
this.data = (this.data + data).slice(-1000)
|
||||||
|
|
||||||
|
if (this.data.indexOf('^') < 0) return
|
||||||
|
|
||||||
|
let split = this.data.split('^')
|
||||||
|
this.data = split[split.length - 1]
|
||||||
|
this.gotPing = this.gotPing || split.includes('pong')
|
||||||
|
}
|
||||||
|
|
||||||
|
openSerial(serial) {
|
||||||
|
return new Promise(res => {
|
||||||
|
serial.open(err => {
|
||||||
|
if (err) {
|
||||||
|
this.log('error', 'Failed to open serial connection: ' + err.message)
|
||||||
|
serial.destroy()
|
||||||
|
}
|
||||||
|
res()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
getDisplay() {
|
||||||
|
return this.currentDisplay.value
|
||||||
|
}
|
||||||
|
|
||||||
|
status() {
|
||||||
|
return {
|
||||||
|
running: Boolean(this.serial.value),
|
||||||
|
log: this.logHistory.join('\n'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const serial = new SerialManager()
|
||||||
|
export default serial;
|
|
@ -1,4 +1,5 @@
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
import dot from 'dot'
|
||||||
import { FileResponse } from 'flaska'
|
import { FileResponse } from 'flaska'
|
||||||
import fs from 'fs/promises'
|
import fs from 'fs/promises'
|
||||||
import fsSync from 'fs'
|
import fsSync from 'fs'
|
||||||
|
@ -14,6 +15,13 @@ export default class ServeHandler {
|
||||||
})
|
})
|
||||||
|
|
||||||
this.index = fsSync.readFileSync(path.join(this.root, 'index.html'))
|
this.index = fsSync.readFileSync(path.join(this.root, 'index.html'))
|
||||||
|
this.loadTemplate(this.index)
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTemplate(indexFile) {
|
||||||
|
this.template = dot.template(indexFile.toString(), { argName: [
|
||||||
|
'version'
|
||||||
|
], strip: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
register(server) {
|
register(server) {
|
||||||
|
@ -51,7 +59,7 @@ export default class ServeHandler {
|
||||||
}
|
}
|
||||||
|
|
||||||
async serveIndex(ctx) {
|
async serveIndex(ctx) {
|
||||||
ctx.body = this.index
|
ctx.body = this.template({ version: this.version })
|
||||||
ctx.type = 'text/html; charset=utf-8'
|
ctx.type = 'text/html; charset=utf-8'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,8 @@ import StaticRoutes from './static_routes.mjs'
|
||||||
import ServeHandler from './serve.mjs'
|
import ServeHandler from './serve.mjs'
|
||||||
import SocketServer from './io.mjs'
|
import SocketServer from './io.mjs'
|
||||||
import EncoderRoutes from './encoder/routes.mjs'
|
import EncoderRoutes from './encoder/routes.mjs'
|
||||||
|
import SerialRoutes from './serial/routes.mjs'
|
||||||
|
import HealthRoutes from './health/routes.mjs'
|
||||||
|
|
||||||
export default class Server {
|
export default class Server {
|
||||||
constructor(http, port, core, opts = {}) {
|
constructor(http, port, core, opts = {}) {
|
||||||
|
@ -26,12 +28,14 @@ export default class Server {
|
||||||
this.routes = {
|
this.routes = {
|
||||||
static: new StaticRoutes(),
|
static: new StaticRoutes(),
|
||||||
encoder: new EncoderRoutes(),
|
encoder: new EncoderRoutes(),
|
||||||
}
|
serial: new SerialRoutes(),
|
||||||
this.routes.serve = new ServeHandler({
|
health: new HealthRoutes(),
|
||||||
|
serve: new ServeHandler({
|
||||||
root: localUtil.getPathFromRoot('../public'),
|
root: localUtil.getPathFromRoot('../public'),
|
||||||
version: this.core.app.running,
|
version: this.core.version,
|
||||||
frontend: config.get('frontend:url'),
|
frontend: config.get('frontend:url'),
|
||||||
})
|
}),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
runCreateServer() {
|
runCreateServer() {
|
||||||
|
@ -100,6 +104,8 @@ export default class Server {
|
||||||
healthChecks = 0
|
healthChecks = 0
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
} else if (ctx.url.startsWith('/assets')) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.log[level]({
|
ctx.log[level]({
|
||||||
|
@ -121,7 +127,7 @@ export default class Server {
|
||||||
|
|
||||||
runCreateSocket() {
|
runCreateSocket() {
|
||||||
this.io = new SocketServer(this.db, this.core.log, this.routes)
|
this.io = new SocketServer(this.db, this.core.log, this.routes)
|
||||||
this.io.init(this.flaska.server)
|
this.io.init(this, this.flaska.server)
|
||||||
}
|
}
|
||||||
|
|
||||||
runStartListen() {
|
runStartListen() {
|
||||||
|
|
|
@ -2,11 +2,22 @@ const m = require('mithril')
|
||||||
var io = require('./socket')
|
var io = require('./socket')
|
||||||
var socket = io()
|
var socket = io()
|
||||||
|
|
||||||
|
let onevent = socket.onevent
|
||||||
|
|
||||||
|
socket.onevent = function(packet) {
|
||||||
|
onevent.call(this, packet) // original call
|
||||||
|
m.redraw()
|
||||||
|
}
|
||||||
|
|
||||||
class Client {
|
class Client {
|
||||||
constructor(socket) {
|
constructor(socket) {
|
||||||
this.socket = socket
|
this.socket = socket
|
||||||
this.isConnected = false
|
this.isConnected = false
|
||||||
|
this.status = {}
|
||||||
|
|
||||||
|
this.socket.on('status', status => {
|
||||||
|
this.status = status
|
||||||
|
})
|
||||||
this.socket.on('disconnect', this.disconnected.bind(this))
|
this.socket.on('disconnect', this.disconnected.bind(this))
|
||||||
this.socket.on('connect', this.connected.bind(this))
|
this.socket.on('connect', this.connected.bind(this))
|
||||||
this.components = new Map()
|
this.components = new Map()
|
||||||
|
@ -61,18 +72,25 @@ class Client {
|
||||||
|
|
||||||
disconnected() {
|
disconnected() {
|
||||||
this.isConnected = false
|
this.isConnected = false
|
||||||
|
this.status = { }
|
||||||
|
this.notifyConnectionChanged()
|
||||||
m.redraw()
|
m.redraw()
|
||||||
}
|
}
|
||||||
|
|
||||||
connected() {
|
connected() {
|
||||||
this.isConnected = true
|
this.isConnected = true
|
||||||
for (let component of this.components) {
|
this.socket.emit('status', {})
|
||||||
if (component[0].ioConnected) {
|
this.notifyConnectionChanged()
|
||||||
component[0].ioConnected()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
m.redraw()
|
m.redraw()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notifyConnectionChanged() {
|
||||||
|
for (let component of this.components) {
|
||||||
|
if (component[0].ioConnectionChanged) {
|
||||||
|
component[0].ioConnectionChanged(this.isConnected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new Client(socket)
|
module.exports = new Client(socket)
|
||||||
|
|
72
app/header.js
Normal file
72
app/header.js
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
const m = require('mithril')
|
||||||
|
const signal = require('@preact/signals-core')
|
||||||
|
const client = require('./api/client')
|
||||||
|
|
||||||
|
const Menu = {
|
||||||
|
oninit: function(vnode) {
|
||||||
|
this.currentPath = '/'
|
||||||
|
this.disconnected = false
|
||||||
|
this.disconnectedTimeout = null
|
||||||
|
client.registerComponent(this)
|
||||||
|
this.effects = signal.effect()
|
||||||
|
this.onbeforeupdate()
|
||||||
|
},
|
||||||
|
|
||||||
|
ioConnectionChanged(connected) {
|
||||||
|
if (connected) {
|
||||||
|
clearTimeout(this.disconnectedTimeout)
|
||||||
|
this.disconnectedTimeout = null
|
||||||
|
this.disconnected = false
|
||||||
|
} else {
|
||||||
|
this.disconnectedTimeout = setTimeout(() => {
|
||||||
|
this.disconnected = true
|
||||||
|
m.redraw()
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onremove: function(vnode) {
|
||||||
|
client.unregisterComponent(this)
|
||||||
|
},
|
||||||
|
|
||||||
|
onbeforeupdate: function() {
|
||||||
|
this.currentPath = m.route.get() || '/'
|
||||||
|
},
|
||||||
|
|
||||||
|
isActive: function(path) {
|
||||||
|
return 'icon ' + (this.currentPath === path ? 'active' : '')
|
||||||
|
},
|
||||||
|
|
||||||
|
createLink: function(link, icon, extra = {}) {
|
||||||
|
return m(m.route.Link, {
|
||||||
|
class: this.isActive(link),
|
||||||
|
href: link
|
||||||
|
}, m('i.fal.fa-' + icon, extra))
|
||||||
|
},
|
||||||
|
|
||||||
|
view: function() {
|
||||||
|
return [
|
||||||
|
this.disconnected
|
||||||
|
? m('.disconnected', [
|
||||||
|
m('.top'), m('.left'), m('.right'), m('.bottom'),
|
||||||
|
m('.error', [
|
||||||
|
m('h4', 'Connecting to server...'),
|
||||||
|
m('p', [
|
||||||
|
'If this is taking too long, the server might be down.',
|
||||||
|
m('br'),
|
||||||
|
'Try restarting the stream computer.',
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
: null,
|
||||||
|
m('nav.row', [
|
||||||
|
m('h2.logo', m(m.route.Link, { href: '/' }, 'Stream controls')),
|
||||||
|
m('div.filler'),
|
||||||
|
this.createLink('/display', 'display', { class: client.status.serial_running ? 'green' : '' }),
|
||||||
|
this.createLink('/encoder', 'video', { class: client.status.encoder_running ? 'red' : '' }),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Menu
|
|
@ -1,5 +1,8 @@
|
||||||
const m = require('mithril')
|
const m = require('mithril')
|
||||||
|
const Header = require('./header')
|
||||||
const Status = require('./page_status')
|
const Status = require('./page_status')
|
||||||
|
const Display = require('./page_display')
|
||||||
|
const Encoder = require('./page_encoder')
|
||||||
window.m = m
|
window.m = m
|
||||||
|
|
||||||
m.route.setOrig = m.route.set
|
m.route.setOrig = m.route.set
|
||||||
|
@ -16,6 +19,9 @@ m.route.link = function(vnode){
|
||||||
|
|
||||||
m.route.prefix = ''
|
m.route.prefix = ''
|
||||||
|
|
||||||
|
m.mount(document.getElementById('header'), Header)
|
||||||
m.route(document.getElementById('main'), '/', {
|
m.route(document.getElementById('main'), '/', {
|
||||||
'/': Status,
|
'/': Status,
|
||||||
|
'/encoder': Encoder,
|
||||||
|
'/display': Display,
|
||||||
})
|
})
|
||||||
|
|
63
app/page_display.js
Normal file
63
app/page_display.js
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
const m = require('mithril')
|
||||||
|
const client = require('./api/client')
|
||||||
|
|
||||||
|
const Display = {
|
||||||
|
oninit: function(vnode) {
|
||||||
|
this.resetStatus()
|
||||||
|
client.registerComponent(this)
|
||||||
|
},
|
||||||
|
|
||||||
|
resetStatus() {
|
||||||
|
this.serialStatus = {
|
||||||
|
running: false,
|
||||||
|
log: '...',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onremove: function(vnode) {
|
||||||
|
client.unregisterComponent(this)
|
||||||
|
},
|
||||||
|
|
||||||
|
ioInit: function() {
|
||||||
|
client.on(this, 'serial.status', status => {
|
||||||
|
this.serialStatus = status
|
||||||
|
})
|
||||||
|
client.emit('serial.status')
|
||||||
|
},
|
||||||
|
|
||||||
|
ioConnectionChanged(connected) {
|
||||||
|
if (!connected) {
|
||||||
|
this.resetStatus()
|
||||||
|
} else {
|
||||||
|
client.emit('serial.status')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
resetClicked() {
|
||||||
|
client.emit('serial.restart')
|
||||||
|
},
|
||||||
|
|
||||||
|
view: function(vnode) {
|
||||||
|
return [
|
||||||
|
m('div.column.settings', [
|
||||||
|
m('h2', 'Serial display'),
|
||||||
|
m('p', 'Status'),
|
||||||
|
m('input', {
|
||||||
|
type: 'text',
|
||||||
|
readonly: true,
|
||||||
|
class: this.serialStatus.running ? 'green' : '',
|
||||||
|
value: !client.isConnected
|
||||||
|
? '<Unknown>'
|
||||||
|
: this.serialStatus.running ? 'Connected' : 'Disconnected',
|
||||||
|
}),
|
||||||
|
m('button.button', {
|
||||||
|
onclick: this.resetClicked.bind(this),
|
||||||
|
}, 'Reconnect'),
|
||||||
|
m('p', 'Log'),
|
||||||
|
m('pre', this.serialStatus.log || '...'),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Display
|
201
app/page_encoder.js
Normal file
201
app/page_encoder.js
Normal file
|
@ -0,0 +1,201 @@
|
||||||
|
const m = require('mithril')
|
||||||
|
const client = require('./api/client')
|
||||||
|
|
||||||
|
const Encoder = {
|
||||||
|
oninit: function(vnode) {
|
||||||
|
this.list_devices = {
|
||||||
|
command: 'D:\\ffmpeg.exe',
|
||||||
|
arguments: '-hide_banner -sources decklink',
|
||||||
|
}
|
||||||
|
this.list_formats = {
|
||||||
|
command: '',
|
||||||
|
arguments: '<missing decklink device>',
|
||||||
|
}
|
||||||
|
this.resetStatus()
|
||||||
|
client.registerComponent(this)
|
||||||
|
},
|
||||||
|
|
||||||
|
resetStatus() {
|
||||||
|
this.settingsChanged = false
|
||||||
|
this.settings = {
|
||||||
|
device: '',
|
||||||
|
format_code: '',
|
||||||
|
command: '',
|
||||||
|
}
|
||||||
|
this.encoderStats= null
|
||||||
|
this.encoderStatus = {
|
||||||
|
starting: false,
|
||||||
|
running: false,
|
||||||
|
log: '...',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onremove: function(vnode) {
|
||||||
|
client.unregisterComponent(this)
|
||||||
|
},
|
||||||
|
|
||||||
|
ioInit: function() {
|
||||||
|
client.on(this, 'encoder.status', status => {
|
||||||
|
this.encoderStatus = status
|
||||||
|
if (!this.settingsChanged) {
|
||||||
|
this.settings = status.settings
|
||||||
|
}
|
||||||
|
this.updateListFormatsHelper()
|
||||||
|
})
|
||||||
|
client.on(this, 'encoder.stats', stats => {
|
||||||
|
this.encoderStats = stats
|
||||||
|
})
|
||||||
|
client.emit('encoder.status')
|
||||||
|
},
|
||||||
|
|
||||||
|
ioConnectionChanged(connected) {
|
||||||
|
if (!connected) {
|
||||||
|
this.resetStatus()
|
||||||
|
} else {
|
||||||
|
client.emit('encoder.status')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
listDecklinkDevices() {
|
||||||
|
client.emit('encoder.run', this.list_devices)
|
||||||
|
},
|
||||||
|
|
||||||
|
listDecklinkFormats() {
|
||||||
|
if (!this.settings.device) return
|
||||||
|
|
||||||
|
client.emit('encoder.run', this.list_formats)
|
||||||
|
},
|
||||||
|
|
||||||
|
startClicked() {
|
||||||
|
client.emit('encoder.start')
|
||||||
|
},
|
||||||
|
|
||||||
|
stopClicked() {
|
||||||
|
client.emit('encoder.stop')
|
||||||
|
},
|
||||||
|
|
||||||
|
saveClicked() {
|
||||||
|
this.settingsChanged = false
|
||||||
|
client.emit('encoder.settings', this.settings)
|
||||||
|
},
|
||||||
|
|
||||||
|
updateListFormatsHelper() {
|
||||||
|
if (this.settings.device) {
|
||||||
|
this.list_formats.command = this.list_devices.command
|
||||||
|
this.list_formats.arguments = `-hide_banner -f decklink -list_formats 1 ${this.settings.device}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSettings(key, vnode) {
|
||||||
|
this.settingsChanged = true
|
||||||
|
this.settings[key] = vnode.target.value
|
||||||
|
|
||||||
|
this.updateListFormatsHelper()
|
||||||
|
},
|
||||||
|
|
||||||
|
view: function(vnode) {
|
||||||
|
let stats = this.encoderStats
|
||||||
|
return [
|
||||||
|
m('div.column.settings', [
|
||||||
|
m('h2', 'Encoder status'),
|
||||||
|
m('p', 'Status'),
|
||||||
|
m('input', {
|
||||||
|
type: 'text',
|
||||||
|
readonly: true,
|
||||||
|
class: this.encoderStatus.running ? 'red'
|
||||||
|
: this.encoderStatus.starting ? 'green' : '',
|
||||||
|
value: (!client.isConnected
|
||||||
|
? '<Unknown>'
|
||||||
|
: this.encoderStatus.stopError ? `Forcefully stopped: ${this.encoderStatus.stopError}`
|
||||||
|
: stats?.errors ? `Streaming: ${stats.errors} Errors`
|
||||||
|
: stats?.showSlowSpeed ? `Streaming (Slow speed)`
|
||||||
|
: this.encoderStatus.running ? 'Streaming'
|
||||||
|
: this.encoderStatus.starting ? 'Starting'
|
||||||
|
: 'Not streaming'
|
||||||
|
)
|
||||||
|
+ ' '
|
||||||
|
+ (stats ? `(fps=${stats.fps} bitrate=${stats.bitrate}kbps speed=${stats.speed % 1 === 0 ? stats.speed : stats.speed.toFixed(3)}x)` : ''),
|
||||||
|
}),
|
||||||
|
m('.row', [
|
||||||
|
m('button.button', {
|
||||||
|
hidden: this.encoderStatus.running || this.encoderStatus.starting,
|
||||||
|
onclick: this.startClicked.bind(this),
|
||||||
|
}, 'Start'),
|
||||||
|
m('button.button', {
|
||||||
|
hidden: !this.encoderStatus.running && !this.encoderStatus.starting,
|
||||||
|
onclick: this.stopClicked.bind(this),
|
||||||
|
}, 'Stop'),
|
||||||
|
]),
|
||||||
|
// Decklink device section
|
||||||
|
m('h2', 'Settings'),
|
||||||
|
m('p', [
|
||||||
|
'Decklink device, example: ',
|
||||||
|
m('span.pre', '-i "DeckLink Mini Recorder"'),
|
||||||
|
]),
|
||||||
|
m('div.row', [
|
||||||
|
m('input', {
|
||||||
|
oncreate: (vnode) => {vnode.dom.setAttribute('spellcheck', 'false')},
|
||||||
|
type: 'text',
|
||||||
|
autocomplete: 'off',
|
||||||
|
value: this.settings.device,
|
||||||
|
oninput: this.updateSettings.bind(this, 'device'),
|
||||||
|
}),
|
||||||
|
m('button.button', {
|
||||||
|
onclick: this.listDecklinkDevices.bind(this),
|
||||||
|
}, 'List devices'),
|
||||||
|
]),
|
||||||
|
m('.row.meta', [
|
||||||
|
'Clicking List devices runs ',
|
||||||
|
m('.pre', `${this.list_devices.command} ${this.list_devices.arguments}`)
|
||||||
|
]),
|
||||||
|
// Decklink format section
|
||||||
|
m('p', [
|
||||||
|
'Input code, example: ',
|
||||||
|
m('span.pre', '-format_code Hi50'),
|
||||||
|
]),
|
||||||
|
m('div.row', [
|
||||||
|
m('input', {
|
||||||
|
oncreate: (vnode) => {vnode.dom.setAttribute('spellcheck', 'false')},
|
||||||
|
type: 'text',
|
||||||
|
autocomplete: 'off',
|
||||||
|
value: this.settings.format_code,
|
||||||
|
oninput: this.updateSettings.bind(this, 'format_code'),
|
||||||
|
}),
|
||||||
|
m('button.button', {
|
||||||
|
disabled: !Boolean(this.settings.device),
|
||||||
|
onclick: this.listDecklinkFormats.bind(this),
|
||||||
|
}, 'List format'),
|
||||||
|
]),
|
||||||
|
m('.row.meta', [
|
||||||
|
'Clicking List format runs ',
|
||||||
|
m('.pre', `${this.list_formats.command} ${this.list_formats.arguments}`)
|
||||||
|
]),
|
||||||
|
// Encoding options
|
||||||
|
m('p', 'Full encode command'),
|
||||||
|
m('textarea', {
|
||||||
|
oncreate: (vnode) => {vnode.dom.setAttribute('spellcheck', 'false')},
|
||||||
|
value: this.settings.command,
|
||||||
|
autocomplete: 'off',
|
||||||
|
oninput: this.updateSettings.bind(this, 'command'),
|
||||||
|
}),
|
||||||
|
m('.row.meta', [
|
||||||
|
'Any ',
|
||||||
|
m('.pre', 'ffmpeg.exe ... -f decklink ...'),
|
||||||
|
'will be replaced with ',
|
||||||
|
m('.pre', `ffmpeg.exe ... ${this.settings.format_code} -f decklink ${this.settings.device} ...`),
|
||||||
|
]),
|
||||||
|
// Controls
|
||||||
|
m('.row', [
|
||||||
|
m('button.button', {
|
||||||
|
hidden: !this.settingsChanged,
|
||||||
|
onclick: this.saveClicked.bind(this),
|
||||||
|
}, 'Save'),
|
||||||
|
]),
|
||||||
|
m('p', 'Log'),
|
||||||
|
m('pre', this.encoderStatus.log || '...'),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = Encoder
|
|
@ -10,19 +10,31 @@ const Status = {
|
||||||
client.unregisterComponent(this)
|
client.unregisterComponent(this)
|
||||||
},
|
},
|
||||||
|
|
||||||
ioInit: function() {
|
startClicked() {
|
||||||
client.on(this, 'encoder.status', status => {
|
client.emit('encoder.start')
|
||||||
console.log('status', status)
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
|
|
||||||
ioConnected: function() {
|
stopClicked() {
|
||||||
client.emit('encoder.status')
|
client.emit('encoder.stop')
|
||||||
},
|
},
|
||||||
|
|
||||||
view: function(vnode) {
|
view: function(vnode) {
|
||||||
|
let display = client.status.serial_display || { first: '', second: ''}
|
||||||
return [
|
return [
|
||||||
m('div', `Hello world, connection status: ${client.isConnected}`),
|
m('pre.status', client.isConnected
|
||||||
|
? display.first.padEnd(20) + '\n' + display.second
|
||||||
|
: ' \n '
|
||||||
|
),
|
||||||
|
m('.status.row', [
|
||||||
|
m('button.button', {
|
||||||
|
hidden: client.status.encoder_running || client.status.encoder_starting,
|
||||||
|
onclick: this.startClicked.bind(this),
|
||||||
|
}, 'Start'),
|
||||||
|
m('button.button', {
|
||||||
|
hidden: !client.status.encoder_running && !client.status.encoder_starting,
|
||||||
|
onclick: this.stopClicked.bind(this),
|
||||||
|
}, 'Stop'),
|
||||||
|
]),
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,11 +17,14 @@ SPI_VFD vfd(2, 3, 4);
|
||||||
|
|
||||||
// | 1 2|
|
// | 1 2|
|
||||||
// |12345678901234567890|
|
// |12345678901234567890|
|
||||||
String vfd_line_top = "Booting up... ";
|
String vfd_line_top = "Filadelfia streamer ";
|
||||||
String vfd_line_bottom = "Waiting for stream. ";
|
String vfd_line_bottom = " ";
|
||||||
unsigned int vfd_line_position = 0;
|
unsigned int vfd_line_position = 0;
|
||||||
|
bool vfd_first_boot = true;
|
||||||
bool vfd_line_print = false;
|
bool vfd_line_print = false;
|
||||||
|
bool show_loading = false;
|
||||||
unsigned long millis_last_incoming_ping = 0;
|
unsigned long millis_last_incoming_ping = 0;
|
||||||
|
unsigned long millis_booting_bar = 0;
|
||||||
|
|
||||||
// the setup function runs once when you press reset or power the board
|
// the setup function runs once when you press reset or power the board
|
||||||
void setup() {
|
void setup() {
|
||||||
|
@ -45,15 +48,19 @@ void update_display() {
|
||||||
|
|
||||||
void check_serial() {
|
void check_serial() {
|
||||||
while (Serial.available() > 0) {
|
while (Serial.available() > 0) {
|
||||||
|
vfd_first_boot = false;
|
||||||
millis_last_incoming_ping = millis();
|
millis_last_incoming_ping = millis();
|
||||||
int incomingByte = Serial.read();
|
int incomingByte = Serial.read();
|
||||||
if (incomingByte == 0 || incomingByte == '^') {
|
if (incomingByte == '~') {
|
||||||
if (Serial.availableForWrite()) {
|
if (Serial.availableForWrite()) {
|
||||||
Serial.write("pong^");
|
Serial.write("pong^");
|
||||||
}
|
}
|
||||||
|
incomingByte = 0;
|
||||||
|
}
|
||||||
|
if (incomingByte == '^') {
|
||||||
incomingByte = vfd_line_position = 0;
|
incomingByte = vfd_line_position = 0;
|
||||||
}
|
}
|
||||||
if (incomingByte < 32) {
|
if (incomingByte <= 0x0F) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -72,18 +79,34 @@ void check_serial() {
|
||||||
|
|
||||||
// the loop function runs over and over again forever
|
// the loop function runs over and over again forever
|
||||||
void loop() {
|
void loop() {
|
||||||
// We've been running for over 50 days and wrapped to zero. Reset all our timers
|
|
||||||
if (millis() < 10000 && millis_last_incoming_ping > 10000) {
|
|
||||||
millis_last_incoming_ping = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
check_serial();
|
check_serial();
|
||||||
|
|
||||||
// If it's been 30 seconds since last incoming data
|
if (vfd_first_boot) {
|
||||||
if (millis() - millis_last_incoming_ping > 30000) {
|
if (!show_loading && millis() > 1000) {
|
||||||
|
show_loading = true;
|
||||||
|
vfd_line_top = "Booting, please wait";
|
||||||
|
vfd_line_bottom = "[------------------]";
|
||||||
|
vfd_line_print = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (millis() - millis_booting_bar > 3158) {
|
||||||
|
millis_booting_bar = millis();
|
||||||
|
int bars = min((int)floor(millis_booting_bar / 3158), 18);
|
||||||
|
for (int i = 0; i < bars; i++) {
|
||||||
|
vfd_line_bottom.setCharAt(i + 1, 0xFF);
|
||||||
|
}
|
||||||
|
vfd_line_print = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's been 60 seconds since startup or
|
||||||
|
// 30 seconds since last message
|
||||||
|
if ((vfd_first_boot && millis() - millis_last_incoming_ping > 60000)
|
||||||
|
|| (!vfd_first_boot && millis() - millis_last_incoming_ping > 30000)) {
|
||||||
|
vfd_first_boot = false;
|
||||||
// |12345678901234567890|
|
// |12345678901234567890|
|
||||||
vfd_line_top = "Stream is down! Try ";
|
vfd_line_top = "Stream down! check ";
|
||||||
vfd_line_bottom = "restarting computer.";
|
vfd_line_bottom = "or restart computer.";
|
||||||
vfd_line_print = true;
|
vfd_line_print = true;
|
||||||
millis_last_incoming_ping = millis();
|
millis_last_incoming_ping = millis();
|
||||||
}
|
}
|
||||||
|
|
10
build-package.json
Normal file
10
build-package.json
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"build": "esbuild app/index.js --bundle --outfile=public/assets/app.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@preact/signals-core": "^1.5.1",
|
||||||
|
"esbuild": "^0.19.5",
|
||||||
|
"mithril": "^2.2.2"
|
||||||
|
}
|
||||||
|
}
|
|
@ -16,7 +16,7 @@ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||||
import('service-core').then(core => {
|
import('service-core').then(core => {
|
||||||
const port = 4510
|
const port = 4510
|
||||||
|
|
||||||
var core = new core.ServiceCore('filadelfia_streamer', import.meta.url, port, '')
|
var core = new core.ServiceCore('filadelfia_streamer', import.meta.url, port)
|
||||||
|
|
||||||
let config = {
|
let config = {
|
||||||
frontend: {
|
frontend: {
|
||||||
|
|
|
@ -34,7 +34,9 @@
|
||||||
"author": "Jonatan Nilsson",
|
"author": "Jonatan Nilsson",
|
||||||
"license": "WTFPL",
|
"license": "WTFPL",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@preact/signals-core": "^1.5.1",
|
||||||
"bunyan-lite": "^1.2.1",
|
"bunyan-lite": "^1.2.1",
|
||||||
|
"dot": "^2.0.0-beta.1",
|
||||||
"flaska": "^1.3.4",
|
"flaska": "^1.3.4",
|
||||||
"nconf-lite": "^2.0.0",
|
"nconf-lite": "^2.0.0",
|
||||||
"serialport": "^12.0.0",
|
"serialport": "^12.0.0",
|
||||||
|
@ -44,6 +46,6 @@
|
||||||
"eltro": "^1.4.4",
|
"eltro": "^1.4.4",
|
||||||
"esbuild": "^0.19.5",
|
"esbuild": "^0.19.5",
|
||||||
"mithril": "^2.2.2",
|
"mithril": "^2.2.2",
|
||||||
"service-core": "^3.0.0-beta.17"
|
"service-core": "^3.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
BIN
public/assets/LEDCalculator.woff2
Normal file
BIN
public/assets/LEDCalculator.woff2
Normal file
Binary file not shown.
|
@ -1,25 +1,350 @@
|
||||||
|
[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
[hidden] { display: none !important; }
|
:root {
|
||||||
|
--bg: #282828;
|
||||||
|
--bg-inner: #323232;
|
||||||
|
--bg-outer: #1c1c1c;
|
||||||
|
--green: #82e854;
|
||||||
|
--red: #ff0013;
|
||||||
|
--orange: hsl(28.1, 100%, 65.1%);
|
||||||
|
--color: #fff;
|
||||||
|
--color-alt: #fff6;
|
||||||
|
--main: white;
|
||||||
|
--main-fg: #fff;
|
||||||
|
--error: red;
|
||||||
|
--error-bg: hsl(0, 75%, 80%);
|
||||||
|
}
|
||||||
|
|
||||||
:root { --bg: #fff; --bg-component: #f3f7ff; --bg-component-half: #f3f7ff77; --bg-component-alt: #ffd99c; --color: #031131; --color-alt: #7a9ad3; --main: #18597d; --main-fg: #fff; --error: red; --error-bg: hsl(0, 75%, 80%); } /* Box sizing rules */
|
/* Box sizing rules */
|
||||||
*, *::before, *::after { box-sizing: border-box;
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Remove default margin */
|
/* Remove default margin */
|
||||||
body, h1, h2, h3, h4, p, figure, blockquote, dl, dd { margin: 0; }
|
|
||||||
|
|
||||||
body { min-height: 100vh; text-rendering: optimizeSpeed; line-height: 1.5; font-size: 16px; font-family: 'Inter var', Helvetica, Arial, sans-serif; font-variation-settings: "slnt" 0; font-feature-settings: "case", "frac", "tnum", "ss02", "calt", "ccmp", "kern"; background: var(--bg); color: var(--color); display: flex; flex-direction: column; }
|
body, h1, h2, h3, h4, h5, p, figure, blockquote, dl, dd, pre {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.italic { font-variation-settings: "slnt" 10deg; }
|
body {
|
||||||
|
min-height: 100vh;
|
||||||
|
text-rendering: optimizeSpeed;
|
||||||
|
line-height: 1.5;
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: 'Inter var', Helvetica, Arial, sans-serif;
|
||||||
|
font-variation-settings: "slnt" 0;
|
||||||
|
font-feature-settings: "case", "frac", "tnum", "ss02", "calt", "ccmp", "kern";
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--color);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
input, button, textarea, select { font: inherit; }
|
main {
|
||||||
|
flex: 2 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
h1 { font-size: 1.88rem; }
|
a, a:visited {
|
||||||
h2 { font-size: 1.66rem; }
|
color: var(--main);
|
||||||
h3 { font-size: 1.44rem; }
|
text-decoration: underline;
|
||||||
h4 { font-size: 1.22rem; }
|
}
|
||||||
h5 { font-size: 1.0rem; }
|
|
||||||
|
|
||||||
.row { display: flex; }
|
.italic {
|
||||||
.column { display: flex; flex-direction: column; }
|
font-variation-settings: "slnt" 10deg;
|
||||||
.filler { flex-grow: 2; }
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
background: rgb(45,45,45);
|
||||||
|
background: linear-gradient(180deg, rgba(45,45,45,1) 0%, rgba(19,19,19,1) 100%);
|
||||||
|
height: 5rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-bottom: 1px solid black;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav .logo {
|
||||||
|
background: url('./filadelfia_white.svg') left center no-repeat;
|
||||||
|
background-size: contain;
|
||||||
|
display: block;
|
||||||
|
padding-left: 4.5rem;
|
||||||
|
align-self: stretch;
|
||||||
|
line-height: 3.75rem;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a {
|
||||||
|
border-bottom: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav .logo.live {
|
||||||
|
background-image: url('./filadelfia.svg');
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a.active {
|
||||||
|
border-bottom-color: var(--orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a.active .fal {
|
||||||
|
background-color: var(--orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
nav a.icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
align-self: center;
|
||||||
|
line-height: 2rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--color-alt);
|
||||||
|
text-align: center;
|
||||||
|
padding: 1em;
|
||||||
|
}
|
||||||
|
footer a,
|
||||||
|
footer a:visited {
|
||||||
|
color: var(--color-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
input, pre, .pre, textarea {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--color);
|
||||||
|
border: 1px solid var(--bg-outer);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:active,
|
||||||
|
input:focus,
|
||||||
|
textarea:active,
|
||||||
|
textarea:focus {
|
||||||
|
border-color: var(--orange);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:read-only,
|
||||||
|
pre {
|
||||||
|
color: var(--orange);
|
||||||
|
border-color: var(--bg-outer);
|
||||||
|
}
|
||||||
|
input.green {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
input.red {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
|
||||||
|
input, button, textarea, select {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.88rem;
|
||||||
|
border-bottom: 1px solid var(--color);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.66rem;
|
||||||
|
border-bottom: 1px solid var(--color);
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.44rem;
|
||||||
|
border-bottom: 1px solid var(--color);
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: 1.22rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h5 {
|
||||||
|
font-size: 1.0rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 0.5rem 0rem 0rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column > * {
|
||||||
|
margin-right: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column > p {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: flex;
|
||||||
|
margin-right: 0;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row > * {
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row input {
|
||||||
|
flex: 2 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
font-weight: bold;
|
||||||
|
background: var(--error);
|
||||||
|
color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fal {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
mask-position: center;
|
||||||
|
mask-size: contain;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
.fal.green {
|
||||||
|
background-color: var(--green);
|
||||||
|
}
|
||||||
|
.fal.red {
|
||||||
|
background-color: var(--red);
|
||||||
|
}
|
||||||
|
.fa-wrench {
|
||||||
|
mask-image: url('./wrench.svg');
|
||||||
|
}
|
||||||
|
.fa-video {
|
||||||
|
mask-image: url('./video.svg');
|
||||||
|
}
|
||||||
|
.fa-display {
|
||||||
|
mask-image: url('./display.svg');
|
||||||
|
}
|
||||||
|
.filler {
|
||||||
|
flex-grow: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
color: var(--color-alt);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-top: -0.75rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta .pre {
|
||||||
|
margin-left: 0.25em;
|
||||||
|
margin-right: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pre {
|
||||||
|
color: var(--color-alt);
|
||||||
|
padding: 0.1em 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 0.25rem 1rem;
|
||||||
|
background: #383838;
|
||||||
|
background: linear-gradient(180deg, #383838 0%, #383838 5%, #222222 100%);
|
||||||
|
border: 1px solid var(--bg-outer);
|
||||||
|
box-shadow: 1px 1px 3px #0005;
|
||||||
|
align-self: center;
|
||||||
|
min-width: 150px;
|
||||||
|
color: var(--color);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover {
|
||||||
|
background: #494949;
|
||||||
|
background: linear-gradient(180deg, #494949 0%, #494949 5%, #2c2c2c 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:active {
|
||||||
|
background: #1b1b1b;
|
||||||
|
color: var(--orange);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:disabled {
|
||||||
|
border-color: black;
|
||||||
|
background: var(--bg-outer);
|
||||||
|
box-shadow: none;
|
||||||
|
color: var(--color-alt);
|
||||||
|
cursor: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings {
|
||||||
|
width: calc(100vw - 2.5rem);
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: 2rem auto 1rem;
|
||||||
|
background: var(--bg-inner);
|
||||||
|
border: 1px solid var(--bg-outer);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
align-self: center;
|
||||||
|
margin-top: 2rem;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre.status {
|
||||||
|
font-family: 'LEDCalculator';
|
||||||
|
font-size: min(7vw, 4rem);
|
||||||
|
background: var(--bg-outer);
|
||||||
|
}
|
||||||
|
|
||||||
|
.disconnected > * {
|
||||||
|
position: fixed;
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
background: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.disconnected .top,
|
||||||
|
.disconnected .left {
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
.disconnected .right,
|
||||||
|
.disconnected .bottom {
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
.disconnected .top,
|
||||||
|
.disconnected .bottom {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.disconnected .left,
|
||||||
|
.disconnected .right {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disconnected .error {
|
||||||
|
border: none;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
position: fixed;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
bottom: 0.5rem;
|
||||||
|
max-width: 100vw;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
1
public/assets/display.svg
Normal file
1
public/assets/display.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--! Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path d="M512 0H64C28.65 0 0 28.65 0 64v288c0 35.35 28.65 64 64 64h149.7l-19.2 64H144C135.2 480 128 487.2 128 496S135.2 512 144 512h288c8.836 0 16-7.164 16-16S440.8 480 432 480h-50.49l-19.2-64H512c35.35 0 64-28.65 64-64V64C576 28.65 547.3 0 512 0zM348.1 480H227.9l19.2-64h81.79L348.1 480zM544 352c0 17.64-14.36 32-32 32H64c-17.64 0-32-14.36-32-32V64c0-17.64 14.36-32 32-32h448c17.64 0 32 14.36 32 32V352z"/></svg>
|
After Width: | Height: | Size: 643 B |
51
public/assets/filadelfia.svg
Normal file
51
public/assets/filadelfia.svg
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="26.65695mm"
|
||||||
|
height="26.65659mm"
|
||||||
|
viewBox="0 0 26.65695 26.65659"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
inkscape:version="1.3.1 (91b66b0783, 2023-11-16)"
|
||||||
|
sodipodi:docname="filadelfia.svg"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#111111"
|
||||||
|
borderopacity="1"
|
||||||
|
inkscape:showpageshadow="0"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pagecheckerboard="1"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:document-units="mm"
|
||||||
|
inkscape:zoom="5.9515614"
|
||||||
|
inkscape:cx="52.171183"
|
||||||
|
inkscape:cy="45.450258"
|
||||||
|
inkscape:window-width="2560"
|
||||||
|
inkscape:window-height="1377"
|
||||||
|
inkscape:window-x="1912"
|
||||||
|
inkscape:window-y="-8"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="layer1" />
|
||||||
|
<defs
|
||||||
|
id="defs1" />
|
||||||
|
<g
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-101.6,-134.67292)">
|
||||||
|
<path
|
||||||
|
id="path215"
|
||||||
|
style="fill:#ff0013;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:0.352778;stroke-opacity:1"
|
||||||
|
d="M 114.9289 135.40879 C 107.97425 135.40879 102.33587 141.04665 102.33587 148.00131 C 102.33587 154.95596 107.97425 160.59382 114.9289 160.59382 C 121.88321 160.59382 127.5209 154.95596 127.5209 148.00131 C 127.5209 141.04665 121.88321 135.40879 114.9289 135.40879 z M 114.89996 136.72706 C 121.09438 136.72706 126.11582 141.749 126.11582 147.94343 C 126.11582 154.13819 121.09438 159.1598 114.89996 159.1598 C 108.70555 159.1598 103.68411 154.13819 103.68411 147.94343 C 103.68411 141.749 108.70555 136.72706 114.89996 136.72706 z " />
|
||||||
|
<path
|
||||||
|
id="path217"
|
||||||
|
d="m 114.13843,138.07299 v 5.04931 h 3.34469 c 0.56197,-1.17581 2.25883,-2.76684 3.26989,-3.64631 l -0.45367,1.06045 c 0.0797,-0.067 -0.70238,1.29046 0.01,2.81799 0.63253,1.35713 1.07068,2.09867 1.2894,3.73097 0.35242,-0.94015 0.56656,-1.62736 1.04034,-2.17628 0.27517,-0.31856 0.31538,-0.93698 0.27058,-1.63971 0.38418,0.42192 0.93521,2.09126 0.82691,3.4364 -0.0536,0.67134 0.10936,2.83175 -2.17523,3.46358 -0.3683,1.33879 -1.24318,2.57704 -2.68993,3.37961 -1.78646,0.9906 -2.58092,0.20249 -3.81987,2.15512 -0.0596,-0.33691 -0.0677,-0.71932 -0.0773,-0.99554 -0.12065,-0.1584 -0.20885,-0.26988 -0.26459,-0.57397 -0.0607,-0.16052 3.6e-4,-0.51082 0.26388,-0.57256 -0.0127,-1.36984 1.36419,-1.15641 1.60549,-1.95474 0.52494,-1.24848 -1.57409,-1.39877 -2.44016,-2.12513 v 6.70877 c 0.30127,0.17286 0.58526,0.44062 0.84808,0.85478 1.29046,-2.03412 3.09104,-0.54151 4.95229,-1.57339 l 1.82986,0.63077 -0.13335,0.1076 c -2.52412,1.23119 -4.95406,-0.67381 -6.70772,2.0902 -1.78223,-2.80846 -4.26437,-0.79586 -6.83295,-2.15229 l 1.93781,-0.67628 c 0.67028,0.37183 1.33315,0.41593 1.96709,0.41522 v -10.6306 h -5.03661 c -0.20144,0.0473 -0.21414,-2.18899 0,-2.13713 l 5.03661,0.002 -7.1e-4,-5.04931 c -0.0487,-0.21449 2.18969,-0.19614 2.1396,0 z m 3.31611,7.18397 h -1.01177 c 0.004,0.32773 0.001,0.62194 0.001,0.85901 0,0.78317 1.59456,1.75789 2.03835,1.95827 0.14993,-1.2125 -0.12029,-1.60549 -0.81315,-2.50049 -0.0826,-0.10689 -0.15416,-0.21167 -0.21449,-0.31679"
|
||||||
|
style="fill:#ff0013;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:0.352778;stroke-opacity:1" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 3.5 KiB |
59
public/assets/filadelfia_white.svg
Normal file
59
public/assets/filadelfia_white.svg
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="26.65695mm"
|
||||||
|
height="26.65659mm"
|
||||||
|
viewBox="0 0 26.65695 26.65659"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
inkscape:version="1.3.1 (91b66b0783, 2023-11-16)"
|
||||||
|
sodipodi:docname="filadelfia.svg"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#111111"
|
||||||
|
borderopacity="1"
|
||||||
|
inkscape:showpageshadow="0"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pagecheckerboard="1"
|
||||||
|
inkscape:deskcolor="#d1d1d1"
|
||||||
|
inkscape:document-units="mm"
|
||||||
|
inkscape:zoom="5.9515614"
|
||||||
|
inkscape:cx="55.027576"
|
||||||
|
inkscape:cy="48.306651"
|
||||||
|
inkscape:window-width="2560"
|
||||||
|
inkscape:window-height="1377"
|
||||||
|
inkscape:window-x="1912"
|
||||||
|
inkscape:window-y="-8"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="layer1" />
|
||||||
|
<defs
|
||||||
|
id="defs1" />
|
||||||
|
<g
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(-101.6,-134.67292)">
|
||||||
|
<path
|
||||||
|
id="path214"
|
||||||
|
d="m 114.92865,134.67292 c 7.36106,0 13.3283,5.96724 13.3283,13.3283 0,7.36106 -5.96724,13.32829 -13.3283,13.32829 -7.36141,0 -13.32865,-5.96723 -13.32865,-13.32829 0,-7.36106 5.96724,-13.3283 13.32865,-13.3283"
|
||||||
|
style="fill:#282828;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.352778" />
|
||||||
|
<path
|
||||||
|
id="path215"
|
||||||
|
d="m 114.92865,135.40881 c -6.95466,0 -12.59275,5.63774 -12.59275,12.5924 0,6.95466 5.63809,12.5924 12.59275,12.5924 6.95431,0 12.5924,-5.63774 12.5924,-12.5924 0,-6.95466 -5.63809,-12.5924 -12.5924,-12.5924"
|
||||||
|
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.352778" />
|
||||||
|
<path
|
||||||
|
id="path216"
|
||||||
|
d="m 114.90007,136.72714 c 6.19442,0 11.21586,5.02179 11.21586,11.21622 0,6.19477 -5.02144,11.21621 -11.21586,11.21621 -6.19442,0 -11.21586,-5.02144 -11.21586,-11.21621 0,-6.19443 5.02144,-11.21622 11.21586,-11.21622"
|
||||||
|
style="fill:#282828;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.352778" />
|
||||||
|
<path
|
||||||
|
id="path217"
|
||||||
|
d="m 114.13843,138.07299 v 5.04931 h 3.34469 c 0.56197,-1.17581 2.25883,-2.76684 3.26989,-3.64631 l -0.45367,1.06045 c 0.0797,-0.067 -0.70238,1.29046 0.01,2.81799 0.63253,1.35713 1.07068,2.09867 1.2894,3.73097 0.35242,-0.94015 0.56656,-1.62736 1.04034,-2.17628 0.27517,-0.31856 0.31538,-0.93698 0.27058,-1.63971 0.38418,0.42192 0.93521,2.09126 0.82691,3.4364 -0.0536,0.67134 0.10936,2.83175 -2.17523,3.46358 -0.3683,1.33879 -1.24318,2.57704 -2.68993,3.37961 -1.78646,0.9906 -2.58092,0.20249 -3.81987,2.15512 -0.0596,-0.33691 -0.0677,-0.71932 -0.0773,-0.99554 -0.12065,-0.1584 -0.20885,-0.26988 -0.26459,-0.57397 -0.0607,-0.16052 3.6e-4,-0.51082 0.26388,-0.57256 -0.0127,-1.36984 1.36419,-1.15641 1.60549,-1.95474 0.52494,-1.24848 -1.57409,-1.39877 -2.44016,-2.12513 v 6.70877 c 0.30127,0.17286 0.58526,0.44062 0.84808,0.85478 1.29046,-2.03412 3.09104,-0.54151 4.95229,-1.57339 l 1.82986,0.63077 -0.13335,0.1076 c -2.52412,1.23119 -4.95406,-0.67381 -6.70772,2.0902 -1.78223,-2.80846 -4.26437,-0.79586 -6.83295,-2.15229 l 1.93781,-0.67628 c 0.67028,0.37183 1.33315,0.41593 1.96709,0.41522 v -10.6306 h -5.03661 c -0.20144,0.0473 -0.21414,-2.18899 0,-2.13713 l 5.03661,0.002 -7.1e-4,-5.04931 c -0.0487,-0.21449 2.18969,-0.19614 2.1396,0 z m 3.31611,7.18397 h -1.01177 c 0.004,0.32773 0.001,0.62194 0.001,0.85901 0,0.78317 1.59456,1.75789 2.03835,1.95827 0.14993,-1.2125 -0.12029,-1.60549 -0.81315,-2.50049 -0.0826,-0.10689 -0.15416,-0.21167 -0.21449,-0.31679"
|
||||||
|
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.352778" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 3.8 KiB |
File diff suppressed because one or more lines are too long
1
public/assets/video.svg
Normal file
1
public/assets/video.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><!--! Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path d="M558.8 99.64c-10.59-5.484-23.37-4.76-33.15 2.099l-102.8 72.04c-7.25 5.062-9 15.05-3.938 22.28C423.1 203.3 433.9 205 441.2 200L544 128v255.9L441.2 312c-7.266-5.047-17.22-3.312-22.28 3.938c-5.062 7.234-3.312 17.22 3.938 22.28l102.8 71.98c5.5 3.844 11.94 5.786 18.38 5.786c5.047 0 10.12-1.203 14.78-3.625C569.4 406.8 576 395.1 576 383.1V128C576 116 569.4 105.2 558.8 99.64zM320 64H64C28.65 64 0 92.65 0 128v256c0 35.35 28.65 64 64 64h256c35.35 0 64-28.65 64-64V128C384 92.65 355.3 64 320 64zM352 384c0 17.64-14.36 32-32 32H64c-17.64 0-32-14.36-32-32V128c0-17.64 14.36-32 32-32h256c17.64 0 32 14.36 32 32V384z"/></svg>
|
After Width: | Height: | Size: 853 B |
1
public/assets/wrench.svg
Normal file
1
public/assets/wrench.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path d="M507.4 114.5c-2.25-9.5-9.626-17-19-19.63c-9.501-2.625-19.63 0-26.63 7L397.9 165.7l-44.25-7.375l-7.376-44.25l63.88-63.88c6.876-6.875 9.626-17.13 7.001-26.5c-2.625-9.5-10.25-16.88-19.75-19.25c-51.75-12.75-105.4 2-143.3 39.75C216.2 82.12 201.9 136.9 215.5 188.1l-193.3 193.3c-29.63 29.63-29.63 77.88 0 107.5C36.47 504.1 55.6 512 75.98 512c20.25 0 39.25-7.875 53.63-22.25l193.1-193.1c52.13 13.75 106.9-.75 144.9-38.88C505.5 219.1 520.4 166.4 507.4 114.5zM445 235.2c-31.75 31.75-78.38 42.63-121.8 28.13l-9.376-3.125L106.1 467.1c-16.63 16.63-45.5 16.63-62.13 0c-17.13-17.13-17.13-45.13 0-62.25l207-207L248.7 188.6c-14.38-43.5-3.625-90.13 28-121.8c22.75-22.63 52.75-34.88 83.76-34.88c6.876 0 13.88 .625 20.75 1.75l-69.26 69.38l13.75 83l83.13 13.88l69.26-69.38C484.9 168.9 472.8 207.5 445 235.2zM79.99 415.1c-8.876 0-16 7.125-16 16s7.125 16 16 16s16-7.125 16-16S88.86 415.1 79.99 415.1z"/></svg>
|
After Width: | Height: | Size: 1.1 KiB |
|
@ -5,12 +5,14 @@
|
||||||
<title>Filadelfia Streamer</title>
|
<title>Filadelfia Streamer</title>
|
||||||
<base href="/">
|
<base href="/">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<link rel="icon" type="image/png" href="/assets/favicon.png">
|
<link rel="icon" type="image/png" href="/assets/filadelfia.svg">
|
||||||
<link rel="stylesheet" type="text/css" href="/assets/font.css">
|
<link rel="stylesheet" type="text/css" href="/assets/font.css">
|
||||||
<link rel="stylesheet" type="text/css" href="/assets/app.css">
|
<link rel="stylesheet" type="text/css" href="/assets/app.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<header id="header"></header>
|
||||||
<main id="main"></main>
|
<main id="main"></main>
|
||||||
|
<footer>Filadelfia streamer | <a href="https://git.nfp.is/TheThing/church_streamer" target="_blank">Source</a> | Version {{=version}}</footer>
|
||||||
<script type="text/javascript" src="/assets/app.js?v=2"></script>
|
<script type="text/javascript" src="/assets/app.js?v=2"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
Loading…
Reference in a new issue