Finished implementing basic streamer UI and controls

master
Jonatan Nilsson 2024-02-20 04:57:49 +00:00
parent c1fcd3a47d
commit 9d71c3b0ea
31 changed files with 1641 additions and 57 deletions

View 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
View File

@ -61,4 +61,5 @@ typings/
config.json
package-lock.json
db.json
public/assets/app.js

BIN
7zas Normal file

Binary file not shown.

295
api/encoder/encoder.mjs Normal file
View 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;

View File

@ -1,14 +1,52 @@
import encoder from './encoder.mjs'
import { runCommand } from './runner.mjs'
export default class EncoderRoutes {
constructor(opts = {}) {
Object.assign(this, opts)
constructor() {
this.core = null
}
registerGlobalIo(io, server) {
this.core = server.core
encoder.init(server.core, io)
}
registerIo(server, ctx) {
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) {
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
View 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
View 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),
}
}
}

View File

@ -7,9 +7,10 @@ export default class SocketServer {
this.routes = routes
}
init(server) {
this.io = new socket(server)
init(server, httpServer) {
this.io = new socket(httpServer)
this.io.on('connection', this.onNewConnection.bind(this))
this.register(server, 'registerGlobalIo')
}
onNewConnection(socket) {
@ -24,16 +25,16 @@ export default class SocketServer {
ctx.socket.safeOn = this.socketSafeOn.bind(this, ctx)
this.register(ctx)
this.register(ctx, 'registerIo')
ctx.socket.emit('data', { bla: 'test' })
}
register(ctx) {
register(ctx, ioKey) {
let keys = Object.keys(this.routes)
for (let key of keys) {
if (this.routes[key].registerIo) {
this.routes[key].registerIo(this, ctx)
if (this.routes[key][ioKey]) {
this.routes[key][ioKey](this, ctx)
}
}
}

25
api/serial/routes.mjs Normal file
View 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
View 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;

View File

@ -1,4 +1,5 @@
import path from 'path'
import dot from 'dot'
import { FileResponse } from 'flaska'
import fs from 'fs/promises'
import fsSync from 'fs'
@ -14,6 +15,13 @@ export default class ServeHandler {
})
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) {
@ -51,7 +59,7 @@ export default class ServeHandler {
}
async serveIndex(ctx) {
ctx.body = this.index
ctx.body = this.template({ version: this.version })
ctx.type = 'text/html; charset=utf-8'
}

View File

@ -5,6 +5,8 @@ import StaticRoutes from './static_routes.mjs'
import ServeHandler from './serve.mjs'
import SocketServer from './io.mjs'
import EncoderRoutes from './encoder/routes.mjs'
import SerialRoutes from './serial/routes.mjs'
import HealthRoutes from './health/routes.mjs'
export default class Server {
constructor(http, port, core, opts = {}) {
@ -26,12 +28,14 @@ export default class Server {
this.routes = {
static: new StaticRoutes(),
encoder: new EncoderRoutes(),
serial: new SerialRoutes(),
health: new HealthRoutes(),
serve: new ServeHandler({
root: localUtil.getPathFromRoot('../public'),
version: this.core.version,
frontend: config.get('frontend:url'),
}),
}
this.routes.serve = new ServeHandler({
root: localUtil.getPathFromRoot('../public'),
version: this.core.app.running,
frontend: config.get('frontend:url'),
})
}
runCreateServer() {
@ -100,6 +104,8 @@ export default class Server {
healthChecks = 0
}
return
} else if (ctx.url.startsWith('/assets')) {
return
}
ctx.log[level]({
@ -121,7 +127,7 @@ export default class Server {
runCreateSocket() {
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() {

View File

@ -2,11 +2,22 @@ const m = require('mithril')
var io = require('./socket')
var socket = io()
let onevent = socket.onevent
socket.onevent = function(packet) {
onevent.call(this, packet) // original call
m.redraw()
}
class Client {
constructor(socket) {
this.socket = socket
this.isConnected = false
this.status = {}
this.socket.on('status', status => {
this.status = status
})
this.socket.on('disconnect', this.disconnected.bind(this))
this.socket.on('connect', this.connected.bind(this))
this.components = new Map()
@ -61,17 +72,24 @@ class Client {
disconnected() {
this.isConnected = false
this.status = { }
this.notifyConnectionChanged()
m.redraw()
}
connected() {
this.isConnected = true
this.socket.emit('status', {})
this.notifyConnectionChanged()
m.redraw()
}
notifyConnectionChanged() {
for (let component of this.components) {
if (component[0].ioConnected) {
component[0].ioConnected()
if (component[0].ioConnectionChanged) {
component[0].ioConnectionChanged(this.isConnected)
}
}
m.redraw()
}
}

72
app/header.js Normal file
View 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

View File

@ -1,5 +1,8 @@
const m = require('mithril')
const Header = require('./header')
const Status = require('./page_status')
const Display = require('./page_display')
const Encoder = require('./page_encoder')
window.m = m
m.route.setOrig = m.route.set
@ -16,6 +19,9 @@ m.route.link = function(vnode){
m.route.prefix = ''
m.mount(document.getElementById('header'), Header)
m.route(document.getElementById('main'), '/', {
'/': Status,
'/encoder': Encoder,
'/display': Display,
})

63
app/page_display.js Normal file
View 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
View 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

View File

@ -10,19 +10,31 @@ const Status = {
client.unregisterComponent(this)
},
ioInit: function() {
client.on(this, 'encoder.status', status => {
console.log('status', status)
})
startClicked() {
client.emit('encoder.start')
},
ioConnected: function() {
client.emit('encoder.status')
stopClicked() {
client.emit('encoder.stop')
},
view: function(vnode) {
let display = client.status.serial_display || { first: '', second: ''}
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'),
]),
]
},
}

View File

@ -17,11 +17,14 @@ SPI_VFD vfd(2, 3, 4);
// | 1 2|
// |12345678901234567890|
String vfd_line_top = "Booting up... ";
String vfd_line_bottom = "Waiting for stream. ";
String vfd_line_top = "Filadelfia streamer ";
String vfd_line_bottom = " ";
unsigned int vfd_line_position = 0;
bool vfd_first_boot = true;
bool vfd_line_print = false;
bool show_loading = false;
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
void setup() {
@ -45,15 +48,19 @@ void update_display() {
void check_serial() {
while (Serial.available() > 0) {
vfd_first_boot = false;
millis_last_incoming_ping = millis();
int incomingByte = Serial.read();
if (incomingByte == 0 || incomingByte == '^') {
if (incomingByte == '~') {
if (Serial.availableForWrite()) {
Serial.write("pong^");
}
incomingByte = 0;
}
if (incomingByte == '^') {
incomingByte = vfd_line_position = 0;
}
if (incomingByte < 32) {
if (incomingByte <= 0x0F) {
continue;
}
@ -72,18 +79,34 @@ void check_serial() {
// the loop function runs over and over again forever
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();
// If it's been 30 seconds since last incoming data
if (millis() - millis_last_incoming_ping > 30000) {
if (vfd_first_boot) {
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|
vfd_line_top = "Stream is down! Try ";
vfd_line_bottom = "restarting computer.";
vfd_line_top = "Stream down! check ";
vfd_line_bottom = "or restart computer.";
vfd_line_print = true;
millis_last_incoming_ping = millis();
}

10
build-package.json Normal file
View 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"
}
}

View File

@ -16,7 +16,7 @@ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
import('service-core').then(core => {
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 = {
frontend: {

View File

@ -34,7 +34,9 @@
"author": "Jonatan Nilsson",
"license": "WTFPL",
"dependencies": {
"@preact/signals-core": "^1.5.1",
"bunyan-lite": "^1.2.1",
"dot": "^2.0.0-beta.1",
"flaska": "^1.3.4",
"nconf-lite": "^2.0.0",
"serialport": "^12.0.0",
@ -44,6 +46,6 @@
"eltro": "^1.4.4",
"esbuild": "^0.19.5",
"mithril": "^2.2.2",
"service-core": "^3.0.0-beta.17"
"service-core": "^3.0.2"
}
}

Binary file not shown.

View File

@ -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 */
*, *::before, *::after { box-sizing: border-box;
/* Box sizing rules */
*,
*::before,
*::after {
box-sizing: border-box;
}
/* 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; }
h2 { font-size: 1.66rem; }
h3 { font-size: 1.44rem; }
h4 { font-size: 1.22rem; }
h5 { font-size: 1.0rem; }
a, a:visited {
color: var(--main);
text-decoration: underline;
}
.row { display: flex; }
.column { display: flex; flex-direction: column; }
.filler { flex-grow: 2; }
.italic {
font-variation-settings: "slnt" 10deg;
}
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%);
}

View 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

View 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

View 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
View 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
View 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

View File

@ -5,12 +5,14 @@
<title>Filadelfia Streamer</title>
<base href="/">
<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/app.css">
</head>
<body>
<header id="header"></header>
<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>
</body>
</html>