refactor for new host
/ release (push) Successful in 30s

This commit is contained in:
2025-12-20 07:15:11 -08:00
parent 95aa404ecd
commit 7cc765ddc4
49 changed files with 1565 additions and 3036 deletions
+14
View File
@@ -0,0 +1,14 @@
on:
push:
tags:
- "*"
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- run: npm ci
- run: npx wrangler deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
-44
View File
@@ -1,44 +0,0 @@
# Simple workflow for deploying static content to GitHub Pages
name: Deploy static content to Pages
on:
# Runs on pushes targeting the default branch
push:
branches: ["master"]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow one concurrent deployment
concurrency:
group: "pages"
cancel-in-progress: true
jobs:
# Single deploy job since we're just deploying
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
with:
submodules: 'recursive'
- name: Setup Pages
uses: actions/configure-pages@v2
- name: Upload artifact
uses: actions/upload-pages-artifact@v1
with:
# Upload entire repository
path: '.'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v1
+2 -4
View File
@@ -1,4 +1,2 @@
/_*
/*.pid
/*.log
/v4/yarn
node_modules
.wrangler
-3
View File
@@ -1,3 +0,0 @@
[submodule "js-util"]
path = js-util
url = git@github.com:sugoidogo/js-util.git
+1 -3
View File
@@ -6,8 +6,6 @@ OBS Browser Source for creating an image based avatar that animates to audio inp
- If it's under 27 (26 or lower), you need to update OBS
- If it's 27, close OBS and install the [OBS Websocket Plugin](https://github.com/obsproject/obs-websocket/releases/tag/5.0.1)
- If it's over 27 (28 or higher), you can skip this step
2. With OBS open, visit the [PNGTube Setup Page](https://sugoidogo.github.io/pngtube2/v8/) and authorize OBS.
2. With OBS open, visit the [PNGTube Setup Page](https://pngtube.sugoidogo.com/) and authorize OBS.
3. After configuring all your settings, hit 'Save & Download' to get your avatar.
You can drag & drop this file into your scene in OBS, or create a browser source, check the 'Local file' option, and select your avatar.
## Support
Support information for all my software is on my [GitHub profile](https://github.com/sugoidogo)
View File
View File
View File
View File
View File

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Before

Width:  |  Height:  |  Size: 520 KiB

After

Width:  |  Height:  |  Size: 520 KiB

Before

Width:  |  Height:  |  Size: 521 KiB

After

Width:  |  Height:  |  Size: 521 KiB

Before

Width:  |  Height:  |  Size: 416 KiB

After

Width:  |  Height:  |  Size: 416 KiB

Before

Width:  |  Height:  |  Size: 423 KiB

After

Width:  |  Height:  |  Size: 423 KiB

-330
View File
@@ -1,330 +0,0 @@
<!DOCTYPE html>
<head>
<title>PNGTuber</title>
</head>
<div id="imgdiv">
<img id="img" class="img">
<img id="simg" class="img">
</div>
<div id="controls">
<img id="logo" src="logo.png" />
<p>Volume threshold</p>
<input type="range" id="volume-range" min="1" max="100" oninput="onVolumeRange(this.value)" />
<input type="number" id="volume-number" min="1" max="100" oninput="onVolumeNumber(this.value)" />
<p>Idle brightness</p>
<input type="range" id="brightness-range" min="1" max="100" oninput="onBrightnessRange(this.value)" />
<input type="number" id="brightness-number" min="1" max="100" oninput="onBrightnessNumber(this.value)" />
<p>Animation transition time</p>
<input type="range" id="time-range" min="0" max="1" step="0.01" oninput="onTimeRange(this.value)" />
<input type="number" id="time-number" min="0" max="1" step="0.01" oninput="onTimeNumber(this.value)" />
<p>Silence transition delay</p>
<input type="range" id="delay-range" min="0" max="1" step="0.1" oninput="onDelayRange(this.value)" />
<input type="number" id="delay-number" min="0" max="1" step="0.1" oninput="onDelayNumber(this.value)" />
<p>Jump height</p>
<input type="range" id="height-range" min="0" max="100" oninput="onHeightRange(this.value)" />
<input type="number" id="height-number" min="0" max="100" oninput="onHeightNumber(this.value)" />
<p>Idle image</p>
<input type="file" accept="image/*" oninput="setIdle(this.files[0])" />
<p>Talking image</p>
<input type="file" accept="image/*" oninput="setTalking(this.files[0])" />
</div>
<style>
* {
font-size: 4vmin;
font-family: Arial, Helvetica, sans-serif;
margin: 0px;
}
.img {
position: absolute;
bottom: 0;
margin-left: auto;
margin-right: auto;
max-width: 100vw;
max-height: 100vh;
}
#img {
opacity: 0;
transition-property: top;
}
#imgdiv {
display: flex;
justify-content: center;
align-items: center;
}
#controls:hover {
opacity: 1;
}
#controls {
width: 99vw;
height: 99vh;
position: absolute;
top: 0px;
background-color: rgba(127, 127, 127, 0.5);
opacity: 0;
}
#logo {
max-width: 100vw;
max-height: 25vh;
}
input[type=range] {
width: 75vw;
-webkit-appearance: none;
appearance: none;
}
input[type=range]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 1vw;
height: 3vh;
background-color: black;
}
input[type=range]::-webkit-slider-runnable-track {
-webkit-appearance: none;
appearance: none;
width: 75vw;
height: 3vh;
background-color: white;
}
#volume-range {
--volume: 0%;
position: relative;
width: 75vw;
height: 3vh;
background-color: white;
}
#volume-range::before {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: var(--volume);
background-color: rgba(0, 255, 0, 0.5);
transition: width 100ms linear;
}
</style>
<script src="https://localforage.github.io/localForage/localforage.min.js"></script>
<script>
const VOLUME = "volume";
const VOLUME_RANGE = document.getElementById("volume-range");
const VOLUME_NUMBER = document.getElementById("volume-number");
const BRIGHTNESS = "brightness";
const BRIGHTNESS_RANGE = document.getElementById("brightness-range");
const BRIGHTNESS_NUMBER = document.getElementById("brightness-number");
const TIME="time";
const TIME_RANGE=document.getElementById("time-range");
const TIME_NUMBER=document.getElementById("time-number");
const DELAY="delay"
const DELAY_RANGE=document.getElementById("delay-range");
const DELAY_NUMBER=document.getElementById("delay-number");
const HEIGHT="height";
const HEIGHT_RANGE=document.getElementById("height-range");
const HEIGHT_NUMBER=document.getElementById("height-number");
const IDLE_IMAGE = "idle-image";
const TALKING_IMAGE = "talking-image";
const IMAGE = document.getElementById("img");
const SIMAGE = document.getElementById("simg");
const DEFAULT_VOLUME = 50;
const DEFAULT_BRIGHTNESS = 90;
const DEFAULT_TIME=0.2;
const DEFAULT_HEIGHT=5;
const DEFAULT_IDLE = "";
const DEFAULT_TALKING = "";
const DEFAULT_DELAY=0.5;
const LEGACY_BRIGHTNESS = "darkness";
const LEGACY_IDLE = "closed";
const LEGACY_TALKING = "open";
const storage = localforage;
let volume;
let brightness;
let time;
let idle;
let talking;
let height;
let delay;
function setHeight(input){
height=input;
IMAGE.style.maxHeight=(100-height)+"vh";
IMAGE.style.top=height+"vh";
SIMAGE.style.maxHeight=(100-height)+"vh";
SIMAGE.style.top=height+"vh";
}
function setTime (input) {
document.getElementById("img").style.transitionDuration=input+'s';
time=input;
}
function setIdle(input) {
const reader = new FileReader();
reader.addEventListener("load", function () {
SIMAGE.src = reader.result;
storage.setItem(IDLE_IMAGE, reader.result);
}, false);
reader.readAsDataURL(input);
}
function setTalking(input) {
const reader = new FileReader();
reader.addEventListener("load", function () {
IMAGE.src = reader.result;
storage.setItem(TALKING_IMAGE, reader.result);
}, false);
reader.readAsDataURL(input);
}
function setBrightness(input){
brightness = input;
SIMAGE.style.setProperty('filter', 'brightness(' + brightness + '%)');
}
function onVolumeRange(input) {
volume = input;
VOLUME_NUMBER.value = input;
storage.setItem(VOLUME, input);
}
function onVolumeNumber(input) {
volume = input;
VOLUME_RANGE.value = input;
storage.setItem(VOLUME, input);
}
function onBrightnessRange(input) {
setBrightness(input)
BRIGHTNESS_NUMBER.value = input;
storage.setItem(BRIGHTNESS, input);
}
function onBrightnessNumber(input) {
setBrightness(input)
BRIGHTNESS_RANGE.value = input;
storage.setItem(BRIGHTNESS, input);
}
function onTimeRange(input) {
setTime(input);
TIME_NUMBER.value=input;
storage.setItem(TIME, input);
}
function onTimeNumber(input){
setTime(input);
TIME_RANGE.value=input;
storage.setItem(TIME, input);
}
function onHeightRange(input){
setHeight(input);
HEIGHT_NUMBER.value=input;
storage.setItem(HEIGHT,input);
}
function onHeightNumber(input){
setHeight(input);
HEIGHT_RANGE.value=input;
storage.setItem(HEIGHT,input);
}
function onDelayRange(input){
delay=input;
DELAY_NUMBER.value=input;
storage.setItem(DELAY,input);
}
function onDelayNumber(input){
delay=input;
DELAY_RANGE.value=input;
storage.setItem(DELAY,input);
}
window.onload = async function () {
try {
volume = await storage.getItem(VOLUME) || DEFAULT_VOLUME;
brightness = await storage.getItem(BRIGHTNESS) || DEFAULT_BRIGHTNESS;
idle = await storage.getItem(IDLE_IMAGE) || DEFAULT_IDLE;
talking = await storage.getItem(TALKING_IMAGE) || DEFAULT_TALKING;
delay = await storage.getItem(DELAY) || DEFAULT_DELAY;
setHeight(await storage.getItem(HEIGHT) || DEFAULT_HEIGHT);
setTime(await storage.getItem(TIME) || DEFAULT_TIME);
VOLUME_RANGE.value = volume;
VOLUME_NUMBER.value = volume;
BRIGHTNESS_RANGE.value = brightness;
BRIGHTNESS_NUMBER.value = brightness;
TIME_RANGE.value=time;
TIME_NUMBER.value=time;
HEIGHT_NUMBER.value=height;
HEIGHT_RANGE.value=height;
DELAY_RANGE.value = delay;
DELAY_NUMBER.value = delay;
IMAGE.src = talking;
SIMAGE.src=idle;
SIMAGE.style.setProperty('filter', 'brightness(' + brightness + '%)');
const volumeVisualizer = document.getElementById("volume-range");
let backoff=null
const volumeCallback = function(averageVolume) {
averageVolume=Math.min(parseInt(averageVolume.data),100)
volumeVisualizer.style.setProperty(
"--volume",
averageVolume + "%"
);
if (averageVolume <= volume) {
if(backoff==null && IMAGE.style.opacity==1){
SIMAGE.style.opacity=1
IMAGE.style.opacity=0
}
} else {
if(backoff!=null){
clearTimeout(backoff)
}
backoff=setTimeout(()=>backoff=null,Math.max(time*2000,delay*1000))
if (IMAGE.style.opacity==0) {
IMAGE.style.opacity=1
SIMAGE.style.opacity=0
IMAGE.style.transitionTimingFunction='ease-out'
IMAGE.style.top="0vh";
setTimeout(()=>{
IMAGE.style.top=height+"vh";
IMAGE.style.transitionTimingFunction='ease-in'
},time*1000);
}
}
};
const startAudio=function (event=null){
if(event){
console.log(event)
}
let ev=new EventSource('http://localhost:5000/audio')
ev.onmessage=volumeCallback;
ev.onerror=startAudio;
window.onbeforeunload=function(event=null){
ev.close()
}
}
startAudio();
} catch (e) {
window.alert(e.message);
throw e;
}
}
</script>
Submodule js-util deleted from 4b923b0b88
+1535
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
{
"devDependencies": {
"wrangler": "^4.56.0"
}
}
-208
View File
@@ -1,208 +0,0 @@
<!DOCTYPE html>
<body>
<table>
<tr>
<td>Idle Image</td>
<td>
<input type="file" accept="image/*" data-id="idle">
</td>
</tr>
<tr>
<td>Active Image</td>
<td>
<input type="file" accept="image/*" data-id="active">
</td>
</tr>
<tr>
<td>OBS URL</td>
<td>
<input type="url" data-id="url">
</td>
</tr>
<tr>
<td>OBS Password</td>
<td>
<input type="password" data-id="password">
</td>
</tr>
<tr>
<td>Audio Sources</td>
<td>
<select data-id="source"></select>
<button id="addSource">Add</button>
<ul>
<li hidden>
<button id="removeSource">Remove</button>
<label></label>
</li>
</ul>
</td>
</tr>
<tr>
<td>Idle Brightness</td>
<td>
<input type="range" data-id="brightness" min="1" max="100">
<input type="number" data-id="brightness" min="1" max="100">
</td>
</tr>
<tr>
<td>Jump Height</td>
<td>
<input type="range" data-id="height" min="0" max="100" />
<input type="number" data-id="height" min="0" max="100" />
</td>
</tr>
<tr>
<td>Animation Time</td>
<td>
<input type="range" data-id="time" min="0" max="1" step="0.01" />
<input type="number" data-id="time" min="0" max="1" step="0.01" />
</td>
</tr>
</table>
</body>
<script>
window.onload = async function () {
// set common variables
const params = new URLSearchParams(window.location.search)
const JSUID = await (await fetch('jsuid')).text() + params.get('id')
const broadcastChannel = new BroadcastChannel(JSUID)
// register oninput function for all inputs
for (const element of document.querySelectorAll('input')) {
element.oninput = async function () {
const value = element.value
const id = element.dataset['id']
for (const input of element.parentElement.querySelectorAll('input')) {
if (input == element) {
continue
}
input.value = value
}
broadcastChannel.postMessage('set ' + id + ' ' + value)
}
}
// override oninput function for all file inputs
for (const element of document.querySelectorAll('[type=file]')) {
element.oninput = async function () {
for (const file of element.files) {
const id = element.dataset['id']
const fileReader = new FileReader()
fileReader.onload = function () {
const value = fileReader.result
broadcastChannel.postMessage('set ' + id + ' ' + value)
}
fileReader.readAsDataURL(file)
}
}
}
/**
* onclick function for removeSource
* @param {Event} event
*/
async function removeSource(event) {
const button = event.target
const parent = button.parentElement
const label = parent.querySelector('label')
const source = label.innerHTML
parent.remove()
broadcastChannel.postMessage('disable ' + source)
console.log(source)
}
/**
* onclick function for addSource
* @param {Event} event
* @param {String} source
*/
async function addSource(event = null, sourceName = null) {
const select = document.querySelector('[data-id="source"]')
const source = select.value
const parent = select.parentElement
const list = parent.querySelector('ul')
const listItem = parent.querySelector('li').cloneNode(true)
const label = listItem.querySelector('label')
const rbutton = listItem.querySelector('button')
rbutton.onclick = removeSource
label.innerHTML = sourceName || source
listItem.hidden = false
list.appendChild(listItem)
if (sourceName == null) {
broadcastChannel.postMessage('enable ' + source)
}
}
document.querySelector('#addSource').onclick = addSource
// register onmessage function for broadcastChannel
broadcastChannel.onmessage = async function (event) {
const args = event.data.split(' ')
switch (args[0]) {
case 'ping': {
broadcastChannel.postMessage('pong')
}
case 'pong': { // Both overlay and dock are now online
// clear all sources, we're about to get new ones
const selector = document.querySelector('[data-id="source"]')
selector.innerHTML = ''
selector.value = ''
const list=document.querySelector('ul')
const listItem=document.querySelector('li')
list.innerHTML=''
list.appendChild(listItem)
break
}
case 'set': { // sync mechanism
const id = args[1]
const value = args[2]
for (const element of document.querySelectorAll('input[data-id=' + id + ']')) {
/**
* if the value is already set in the ui, reject the change and trigger the input event,
* syncing to the existing value. ranges are set by the browser on creation,
* and their synced number input always comes next, so never reject, instead allowing the number to set the range.
* always reject file inputs, which must be user-selected, triggering sending of any selected files.
*/
if ((element.value == '' || element.type == 'range' || element.tagName.toLowerCase() == 'select') && element.type != 'file') {
element.value = value
} else {
element.oninput()
}
}
break
}
case 'add': {
const value = args[1]
const selector = document.querySelector('[data-id="source"]')
const option = document.createElement('option')
option.value = value
option.innerHTML = value
selector.appendChild(option)
broadcastChannel.postMessage('get source')
break
}
case 'remove': {
const value = args[1]
if (selector.value == value) {
selector.value = ''
}
const selector = document.querySelector('[data-id="source"]')
const option = selector.querySelector('[value="' + value + ']"')
option.remove()
break
}
case 'enable': {
const source = args[1]
addSource(null, source)
}
case 'disable': {
const source=args[1]
const label=document.querySelector('[innerHTML="'+source+'"]')
const listItem=label.parentElement
listItem.remove()
}
default: {
window.alert(JSUID + '-dock did not understand this message:\n' + event.data)
}
}
}
// send startup completed ping
broadcastChannel.postMessage('ping')
}
</script>
-1
View File
@@ -1 +0,0 @@
sugoidogo-pngtube-v4
File diff suppressed because one or more lines are too long
-218
View File
@@ -1,218 +0,0 @@
<!DOCTYPE html>
<body>
<img data-id="idle">
<img data-id="active">
</body>
<style>
body {
display: flex;
justify-content: center;
align-items: center;
}
img {
position: absolute;
bottom: 0vh;
margin-left: auto;
margin-right: auto;
height: 100vh;
}
[data-id="active"] {
opacity: 0;
transition-property: bottom;
}
</style>
<script src="https://cdn.rawgit.com/mozilla/localForage/master/dist/localforage.js"></script>
<script src="obs-ws.min.cjs"></script>
<script>
window.onload = async function () {
// set persistant variables
const params = new URLSearchParams(window.location.search)
const JSUID = await (await fetch('jsuid')).text() + params.get('id')
const broadcastChannel = new BroadcastChannel(JSUID)
localStorage = localforage.createInstance({ name: JSUID })
const obs = new OBSWebSocket()
const SIMAGE = document.querySelector('[data-id="idle"]')
const IMAGE = document.querySelector('[data-id="active"]')
let backoff = false
// set defaults
const config = {
idle: '',
active: '',
url: 'ws://localhost:4455',
password: '',
sources: [],
brightness: 50,
height: 1,
time: 0.2,
}
var sources = []
IMAGE.style.transitionTimingFunction = 'ease-out'
// register ontransitionend function for active image
IMAGE.ontransitionend = async function () {
if (IMAGE.style.transitionTimingFunction == 'ease-out') {
IMAGE.style.transitionTimingFunction = 'ease-in'
} else {
IMAGE.style.transitionTimingFunction = 'ease-out'
}
if (IMAGE.style.bottom == '0vh') {
backoff = false
} else {
IMAGE.style.bottom = '0vh'
}
}
// register oninputvolumemeters for obs
obs.addListener('InputVolumeMeters', function (data) {
// construct event object
const inputs = data.inputs
const event = {}
for (const input of inputs) {
event[input.inputName] = input.inputLevelsMul
}
// remove sources that no longer exist
for (const source of sources) {
if (!source in event) {
sources.splice(sources.indexOf(source), 1)
broadcastChannel.postMessage('remove ' + source)
}
}
// add newly created sources
for (const source in event) {
if (!sources.includes(source)) {
sources.push(source)
//console.log(source)
broadcastChannel.postMessage('add ' + source)
}
}
// if the selected source is missing, exit function
if (!config.source in event) {
return
}
// get the audio level of the selected sources
/**
* AUDIO LEVEL FORMAT
* event[source][channel][?]=float
* each channel contains an array of 3 floats
*/
let level = 0
for (const source of config.sources) {
level += event[source][0][0] || 0
}
// animate the image if neccessary
if (backoff) {
return
}
if (level != 0) {
if (IMAGE.style.opacity == 1) {
return
}
if (config.time != 0) {
backoff = true
}
IMAGE.style.opacity = 1
SIMAGE.style.opacity = 0
IMAGE.style.bottom = config.height + 'vh'
} else {
SIMAGE.style.opacity = 1
IMAGE.style.opacity = 0
}
})
// register onmessage function for broadcastChannel
broadcastChannel.onmessage = async function (event) {
const args = event.data.split(' ')
switch (args[0]) {
case 'ping': {
broadcastChannel.postMessage('pong')
}
case 'pong': { // Both overlay and dock are now online
// trigger sync mechanism
for (const id in config) {
broadcastChannel.postMessage('set ' + id + ' ' + config[id])
}
for (const source of config.sources) {
broadcastChannel.postMessage('enable ' + source)
}
sources = []
break
}
case 'set': {
const id = args[1]
const value = args[2]
for (const element of document.querySelectorAll('[data-id=' + id + ']')) {
element.src = value
}
if (id == 'brightness') {
SIMAGE.style.filter = 'brightness(' + config.brightness + '%)'
}
if (id == 'height') {
IMAGE.style.height = 100 - config.height + 'vh'
SIMAGE.style.height = 100 - config.height + 'vh'
}
if (id == 'time') {
IMAGE.style.transitionDuration = value + 's'
}
if (id == 'password' || id == 'url') { // try to use new connection information
//obs.disconnect()
obs.connect(url = config.url, password = config.password, {
eventSubscriptions: (1 << 16),
})
}
localStorage.setItem(id, value)
config[id] = value
break
}
case 'get': {
const id = args[1]
const value = config[id]
broadcastChannel.postMessage('set ' + id + ' ' + value)
break
}
case 'enable': {
const source = args[1]
config.sources.push(source)
localStorage.setItem('sources', JSON.stringify(config.sources))
break
}
case 'disable': {
const source = args[1]
const index = config.sources.indexOf(source)
config.sources.splice(index, 1)
localStorage.setItem('sources', JSON.stringify(config.sources))
break
}
default: {
window.alert(JSUID + '-overlay did not understand this message:\n' + event.data)
}
}
}
// Sync config. If no value is stored, the default is used.
// The resulting value is then sent to the dock (if loaded),
// which may reject the value if user has already set a different one.
// This mechanism creates a config priority of dock, storage, defaults.
for (const id in config) {
config[id] = await localStorage.getItem(id) || config[id]
broadcastChannel.postMessage('set ' + id + ' ' + config[id])
}
config.sources = JSON.parse(await localStorage.getItem('sources')) || []
const source=await localStorage.getItem('source')
if(source){
config.sources.push(source)
localStorage.removeItem('source')
}
// apply config
IMAGE.src = config.active
IMAGE.style.height = 100 - config.height + 'vh'
IMAGE.style.transitionDuration = config.time + 's'
SIMAGE.src = config.idle
SIMAGE.style.height = 100 - config.height + 'vh'
SIMAGE.style.filter = 'brightness(' + config.brightness + '%)'
// send startup completed ping
broadcastChannel.postMessage('ping')
// attempt obs connection
obs.connect(url = config.url, password = config.password, {
eventSubscriptions: (1 << 16),
})
}
</script>
-578
View File
@@ -1,578 +0,0 @@
<!DOCTYPE html>
<body>
<div id="status">Loading...</div>
<span id="connectionSettings" hidden>
<table>
<tr>
<td>Websocket URL:</td>
<td><input id="url" type="url"></td>
</tr>
<tr>
<td>Websocket Password:</td>
<td><input id="password" type="password"></td>
</tr>
</table>
<button id="connect" onclick="connect()">Connect</button>
<button id="useQR" onclick="useQrCode()">Use QR Code</button>
<br>
<span id="qr" hidden>
<video id="qrvideo" hidden></video>
<br>
<span>Make sure this device and the one running OBS are on the same network.</span>
<br>
<button onclick="screenCapture()">Capture from Screen</button>
<button onclick="cameraCapture()">Capture from Camera</button>
<br>
</span>
</span>
<span id="connected">
<div id="upgradePrompt" class="bordered" hidden>
<span id="name"></span>
<span> is using another version of PNGTube. Switch to this version?</span>
<button onclick="upgrade(this.parentElement.id)">Switch</button>
<button onclick="remove(this.parentElement.id)">Remove</button>
</div>
<span id="upgrades"></span>
<div id="settings" class="bordered" hidden>
<div id="name"></div>
<table>
<tr>
<td>Idle Image</td>
<td>
<input type="file" accept="image/*" data-id="idle">
</td>
</tr>
<tr>
<td>Active Image</td>
<td>
<input type="file" accept="image/*" data-id="active">
</td>
</tr>
<tr>
<td>Audio Sources</td>
<td>
<select data-id="source"></select>
<button onclick="addSource(this.parentElement)">Add</button>
<ul>
<li hidden>
<button onclick="this.parentElement.remove()">Remove</button>
<button onclick="addGate(this.parentElement)" hidden id="noiseGate">Add Noise Gate</button>
<label data-id="source"></label>
</li>
</ul>
</td>
</tr>
<tr>
<td>Idle Brightness</td>
<td>
<input type="range" data-id="brightness" min="1" max="100">
<input type="number" data-id="brightness" min="1" max="100">
</td>
</tr>
<tr>
<td>Jump Height</td>
<td>
<input type="range" data-id="height" min="0" max="100" />
<input type="number" data-id="height" min="0" max="100" />
</td>
</tr>
<tr>
<td>Animation Time</td>
<td>
<input type="range" data-id="time" min="0" max="1" step="0.01" />
<input type="number" data-id="time" min="0" max="1" step="0.01" />
</td>
</tr>
</table>
<button onclick="save(this.parentElement.id)">Save and Apply</button>
<button onclick="remove(this.parentElement.id)">Remove</button>
</div>
<span id="inputSettings"></span>
<span id="newInput">
<table>
<tr>
<td>Scene:</td>
<td><select id="scene"></select></td>
</tr>
<tr>
<td>Name:</td>
<td><input type="text" id="newName"></td>
</tr>
</table>
<button onclick="addNew()">Add new PNGTube</button>
</span>
</span>
</body>
<style>
.bordered {
border: 1px solid black;
}
video {
max-width: 100vw;
max-height: 50vh;
}
</style>
<script
src="https://cdn.jsdelivr.net/npm/obs-websocket-js@5.0.1/dist/obs-ws.min.js"
integrity="sha256-UbkntZrYTokZf8tldcF9nmOn5CNgcmDP+MNsUcKUpEE="
crossorigin="anonymous">
</script>
<script
src="https://cdn.jsdelivr.net/npm/qr-scanner@1.4.1/qr-scanner.umd.min.js"
integrity="sha256-jstRoCLjzwXwLN2o0DMkkwRHtqYCQMxrRkktAHwZekU="
crossorigin="anonymous"></script>
<script>
'use strict'
console.info('loading settings or defaults')
document.querySelector('#url').value=localStorage.getItem('url') || 'ws://localhost:4455'
document.querySelector('#password').value=localStorage.getItem('password') || ''
console.info('initializing obs-websocket-js')
const obs = new OBSWebSocket()
obs.addListener('ConnectionClosed',function onConnectionClosed(error){
document.querySelector('#connected').hidden=true
document.querySelector('#connectionSettings').hidden=false
switch(error.code){
case 1006:
document.querySelector('#status').innerHTML='obs websocket not found.<br>'+
'Is <a href="https://github.com/obsproject/obs-websocket/releases/latest">obs-websocket</a> installed?<br>'+
'Is obs open? Check your websocket settings in OBS under the Tools menu.'
break
case 4009:
document.querySelector('#status').innerHTML=error.message.split(' ').slice(-3).join(' ').toLowerCase().replace('authentication','Password')+
'<br>You can view or change your websocket password in OBS under the Tools menu.'
break
default:
document.querySelector('#status').innerHTML='connection closed: Error '+error.code+': '+error.message
break
}
console.warn('connection closed', error.code, error.message)
document.querySelector('#upgrades').innerHTML=''
document.querySelector('#inputSettings').innerHTML=''
document.querySelector('select').innerHTML=''
})
obs.addListener('ConnectionOpened',function onConnectionOpened(){
console.info('connected to socket, saving url')
localStorage.setItem('url',document.querySelector('#url').value)
document.querySelector('#connectionSettings').hidden=true
})
obs.addListener('Identified',async function onIdentified(){
console.info('authentication passed, saving password')
localStorage.setItem('password',document.querySelector('#password').value)
const video=document.querySelector('#qrvideo')
if(video.srcObject!=null){
video.srcObject.getTracks().forEach(function stopCapture(track){
track.stop()
})
video.srcObject=null
}
document.querySelector('#status').innerHTML='Loading OBS data...'
const loadingPromises=[]
console.info('getting input list')
loadingPromises.push(obs.call('GetInputList',{}).then(function onReceiveBrowserInputList(response){
console.info('got input list:',response)
const inputPromises=[]
console.info('getting input settings')
for(const input of response.inputs){
inputPromises.push(obs.call('GetInputSettings',{inputName:input.inputName}).then(function onReceiveBrowserInputSettings(response){
Object.assign(input,response)
checkInput(input)
return input
}).catch(function onParseError(error){
console.error(input.inputName,error)
}))
}
loadingPromises.push(Promise.all(inputPromises).then(function onBrowserInputsFiltered(data){
console.info('got input settings:',data)
}))
}))
console.info('getting scene list')
loadingPromises.push(obs.call('GetSceneList',{}).then(function onReceiveSceneList(response){
console.info('got scene list:',response)
const scenes=document.querySelector('#scene')
for(const scene of response.scenes){
const option=document.createElement('option')
option.value=scene.sceneName
option.innerHTML=scene.sceneName
scenes.appendChild(option)
}
}))
while(loadingPromises.length!=0){
await loadingPromises.shift()
}
console.info('loading finished')
document.querySelector('#status').innerHTML='Ready'
document.querySelector('#connected').hidden=false
})
obs.addListener('InputCreated',checkInput)
obs.addListener('InputRemoved',onInputRemoved)
obs.addListener('InputNameChanged',function onInputNameChanged(input){
console.info('input name changed',input)
const element=document.querySelector('#'+input.oldInputName)
element.id=input.inputName
element.querySelector('#name').innerHTML=input.inputName
obs.call('GetPersistentData',{
realm:'OBS_WEBSOCKET_DATA_REALM_GLOBAL',
slotName:'pngtubev5'+input.oldInputName
}).then(function(data){
obs.call('SetPersistentData',{
realm:'OBS_WEBSOCKET_DATA_REALM_GLOBAL',
slotName:'pngtubev5'+input.oldInputName,
slotValue:{}
})
obs.call('SetPersistentData',{
realm:'OBS_WEBSOCKET_DATA_REALM_GLOBAL',
slotName:'pngtubev5'+input.inputName,
slotValue:data.slotValue
}).then(function(){
setURL(input.inputName)
})
})
})
console.info('connecting to obs')
connect()
function log(...data){
console.log(data)
}
async function connect(){
document.querySelector('#status').innerHTML='Connecting to OBS...'
try{
await obs.connect(
document.querySelector('#url').value,
document.querySelector('#password').value
)
}catch(e){
return false
}
return true
}
function useQrCode(){
document.querySelector('#qr').hidden=false
}
let interval
function startQr(videoSource){
const video=document.querySelector('#qrvideo')
video.srcObject=videoSource
video.play()
video.hidden=false
interval=setInterval(qrScan,1000)
}
async function screenCapture(){
const mediaSource=await navigator.mediaDevices.getDisplayMedia()
startQr(mediaSource)
}
async function cameraCapture(){
const videoSource=await navigator.mediaDevices.getUserMedia({video:true})
startQr(videoSource)
}
function qrScan(){
const video=document.querySelector('#qrvideo')
QrScanner.scanImage(video,{returnDetailedScanResult:true})
.then(onQrScan).catch(function(error){console.warn(error)})
}
async function onQrScan(result){
var data=result.data.split('/')
if(data.shift()!='obsws:'){
return false
}
clearInterval(interval)
data.shift()
const fullurl='ws://'+data.shift()
const port=fullurl.split(':').pop()
const localurl='ws://localhost:'+port
const password=data.shift()
const obsurl=document.querySelector('#url')
const obspassword=document.querySelector('#password')
obspassword.value=password
obsurl.value=localurl
if(await connect()){
return true
}
obsurl.value=fullurl
if(await connect()){
return true
}
interval=setInterval(qrScan,1000)
return false
}
function getOverlayURL(name=''){
const url=new URL(window.location.href+'overlay.html')
const searchParams=url.searchParams
const obsurl=document.querySelector('#url')
const obspassword=document.querySelector('#password')
searchParams.set('name',name)
searchParams.set('obsurl',obsurl.value)
if(obspassword.value){
searchParams.set('obspassword',obspassword.value)
}
return url.href
}
async function setURL(name,currentURL=getOverlayURL()){
const url=new URL(currentURL)
const searchParams=url.searchParams
const obsurl=document.querySelector('#url')
const obspassword=document.querySelector('#password')
if(searchParams.get('name')!=name){
searchParams.set('name',name)
}
if(searchParams.get('obsurl')!=obsurl.value){
searchParams.set('obsurl',obsurl.value)
}
if(searchParams.get('obspassword')!=obspassword.value){
searchParams.set('obspassword',obspassword.value)
}
if(currentURL!=url.href){
console.log('updating url for '+name)
obs.call('SetInputSettings',{
inputName:name,
inputSettings:{url:url.href}
})
}
}
function addLegacy(name){
console.info('adding upgrade prompt for '+name)
const upgradePrompt=document.querySelector('#upgradePrompt').cloneNode(true)
upgradePrompt.hidden=false
upgradePrompt.querySelector('#name').innerHTML=name
upgradePrompt.id=name
document.querySelector('#upgrades').appendChild(upgradePrompt)
}
const settings={}
function addSettings(name){
console.info('getting settings for',name)
const settings=document.querySelector('#settings').cloneNode(true)
settings.hidden=false
settings.querySelector('#name').innerHTML=name
settings.id=name
obs.call('GetPersistentData',{
realm:'OBS_WEBSOCKET_DATA_REALM_GLOBAL',
slotName:'pngtubev5'+name
}).then(function(data){
data=data.slotValue
console.log('got settings for ',name,data)
if(data==null){
data={
idle:'',
active:'',
source:[],
brightness:50,
height:1,
time:0.2
}
}
settings[name]=data
for(const input of settings.querySelectorAll('input[type=range],input[type=number]')){
const id=input.dataset.id
let input2
if(input.type=='range'){
input2=settings.querySelector('input[type=number][data-id='+id+']')
}else{
input2=settings.querySelector('input[type=range][data-id='+id+']')
}
input.oninput=function(){
input2.value=input.value
}
input.value=data[id]
}
for(const source of data.source){
addSource(settings,source)
}
})
document.querySelector('#inputSettings').appendChild(settings)
}
function upgrade(name){
document.querySelector('#'+name).remove()
setURL(name)
addSettings(name)
}
function remove(name){
const input={inputName:name}
obs.call('RemoveInput',input).then(()=>onInputRemoved(input))
}
function addNew(){
const scene=document.querySelector('#scene').value
const name=document.querySelector('#newName').value
obs.call('CreateInput',{
sceneName:scene,
inputName:name,
inputKind:'browser_source',
inputSettings:{
url:getOverlayURL(name)
}
}).catch(function onCreateInputError(error){
document.querySelector('#status').innerHTML='Adding PNGTube failed: Error '+error.code+': '+error.message
})
}
function checkInput(input){
const option=document.createElement('option')
option.value=input.inputName
option.innerHTML=input.inputName
for(const select of document.querySelectorAll('select[data-id=source]')){
select.appendChild(option)
}
if(input.inputKind!='browser_source'){
return
}
if('url' in input.inputSettings === false){
return
}
if(input.inputSettings.url.startsWith(window.location.href)){
setURL(input.inputName,input.inputSettings.url)
addSettings(input.inputName)
return
}
if(input.inputSettings.url.startsWith('https://sugoidogo.github.io/pngtube')){
addLegacy(input.inputName)
}
}
function genericError(error,prefix=''){
console.log(error)
document.querySelector('#status').innerHTML=prefix+'Error '+error.code+': '+error.message
}
function onInputRemoved(input){
console.info('input removed',input)
document.querySelector('#'+input.inputName).remove()
for(const option of document.querySelectorAll('option[value='+input.inputName+']')){
option.remove()
}
obs.call('SetPersistentData',{
realm:'OBS_WEBSOCKET_DATA_REALM_GLOBAL',
slotName:'pngtubev5'+input.inputName,
slotValue:{}
})
}
function save(name){
const settings=document.querySelector('#'+name)
const data=settings[name]
for(const input of settings.querySelectorAll('input[type=number]')){
data[input.dataset.id]=input.value
}
data.source=[]
for(const source of settings.querySelectorAll('label[data-id=source]')){
if(source.innerHTML){
data.source.push(source.innerHTML)
}
}
const files=[]
for(const image of settings.querySelectorAll('input[type=file]')){
for(const file of image.files){
files.push(new Promise(function(resolve){
const id=image.dataset.id
const fileReader = new FileReader()
fileReader.onload = function () {
data[id]=fileReader.result
resolve()
}
fileReader.readAsDataURL(file)
}))
}
}
Promise.all(files).then(function(){
console.log('saving data:',data)
obs.call('SetPersistentData',{
realm:'OBS_WEBSOCKET_DATA_REALM_GLOBAL',
slotName:'pngtubev5'+name,
slotValue:data
}).then(function(){
obs.call('SetInputSettings',{
inputName:name,
inputSettings:{url:''}
}).then(function(){
obs.call('SetInputSettings',{
inputName:name,
inputSettings:{url:getOverlayURL(name)}
}).catch(genericError)
})
})
let width=0
let height=0
let imagePromises=[]
for(const source of [data.active,data.idle]){
imagePromises.push(new Promise(function(resolve){
const image=new Image();
image.onload=function(){
if(image.naturalWidth>width){
width=image.naturalWidth
}
if(image.naturalHeight>height){
height=image.naturalHeight
}
resolve()
}
image.src=source
}))
}
Promise.all(imagePromises).then(function(){
obs.call('SetInputSettings',{
inputName:name,
inputSettings:{
width:width,
height:height
}
})
})
})
}
function addSource(parentElement,value=null){
if(value==null){
value=parentElement.querySelector('select').value
}
const listItem=parentElement.querySelector('li').cloneNode(true)
listItem.hidden=false
listItem.querySelector('label').innerHTML=value
parentElement.querySelector('ul').appendChild(listItem)
obs.call('GetSourceFilterList',{
sourceName:value
}).then(function(data){
for(const filter of data.filters){
if(filter.filterKind=='noise_gate_filter'){
return
}
}
listItem.querySelector('#noiseGate').hidden=false
})
}
function addGate(parentElement){
const name=parentElement.querySelector('label').innerHTML
obs.call('CreateSourceFilter',{
sourceName:name,
filterName:'Noise Gate',
filterKind:'noise_gate_filter',
filterSettings:{
attack_time:25,
release_time:50,
close_threshold:-60,
open_threshold:-55
}
}).then(function(){
parentElement.querySelector('#noiseGate').hidden=true
obs.call('OpenInputFiltersDialog',{inputName:name})
})
}
</script>
-109
View File
@@ -1,109 +0,0 @@
<!DOCTYPE html>
<body>
<img data-id="idle">
<img data-id="active">
</body>
<style>
body {
display: flex;
justify-content: center;
align-items: center;
}
img {
position: absolute;
bottom: 0vh;
margin-left: auto;
margin-right: auto;
height: 100vh;
}
[data-id="active"] {
opacity: 0;
transition-property: bottom;
}
</style>
<script
src="https://cdn.jsdelivr.net/npm/obs-websocket-js@5.0.1/dist/obs-ws.min.js"
integrity="sha256-UbkntZrYTokZf8tldcF9nmOn5CNgcmDP+MNsUcKUpEE="
crossorigin="anonymous">
</script>
<script>
window.onload = async function () {
// set persistant variables
const params = new URLSearchParams(window.location.search)
const obs = new OBSWebSocket()
const SIMAGE = document.querySelector('[data-id="idle"]')
const IMAGE = document.querySelector('[data-id="active"]')
let backoff = false
IMAGE.style.transitionTimingFunction = 'ease-out'
// attempt obs connection
await obs.connect(url = params.get('obsurl'), password = params.get('obspassword'), {
eventSubscriptions: (1 << 16),
})
// get config
const config=(await obs.call('GetPersistentData',{
realm:'OBS_WEBSOCKET_DATA_REALM_GLOBAL',
slotName:'pngtubev5'+params.get('name')
})).slotValue
console.log(config)
// register ontransitionend function for active image
IMAGE.ontransitionend = async function () {
if (IMAGE.style.transitionTimingFunction == 'ease-out') {
IMAGE.style.transitionTimingFunction = 'ease-in'
} else {
IMAGE.style.transitionTimingFunction = 'ease-out'
}
if (IMAGE.style.bottom == '0vh') {
backoff = false
} else {
IMAGE.style.bottom = '0vh'
}
}
// register oninputvolumemeters for obs
obs.addListener('InputVolumeMeters', function (data) {
// construct event object
const inputs = data.inputs
const event = {}
for (const input of inputs) {
event[input.inputName] = input.inputLevelsMul
}
// get the audio level of the selected sources
/**
* AUDIO LEVEL FORMAT
* event[source][channel][?]=float
* each channel contains an array of 3 floats
*/
let level = 0
for (const source of config.source) {
level += event[source][0][0] //|| 0
}
// animate the image if neccessary
if (backoff) {
return
}
if (level != 0) {
if (IMAGE.style.opacity == 1) {
return
}
if (config.time != 0) {
backoff = true
}
IMAGE.style.opacity = 1
SIMAGE.style.opacity = 0
IMAGE.style.bottom = config.height + 'vh'
} else {
SIMAGE.style.opacity = 1
IMAGE.style.opacity = 0
}
})
// apply config
IMAGE.src = config.active
IMAGE.style.height = 100 - config.height + 'vh'
IMAGE.style.transitionDuration = config.time + 's'
SIMAGE.src = config.idle
SIMAGE.style.height = 100 - config.height + 'vh'
SIMAGE.style.filter = 'brightness(' + config.brightness + '%)'
}
</script>
-22
View File
@@ -1,22 +0,0 @@
body {
display: flex;
justify-content: center;
align-items: center;
}
img {
position: absolute;
bottom: 0%;
width: 100%;
height: 100%;
object-fit: contain;
opacity: 0;
}
#idle:not(.active) {
opacity: 1;
}
#active.active{
opacity: 1;
}
-12
View File
@@ -1,12 +0,0 @@
@import url('basic.css');
body {
--blink-min: 1;
--blink-max: 10;
--blink-time: 0.1;
}
#active-blink.blink.active,
#idle-blink.blink:not(.active) {
opacity: 1;
}
-16
View File
@@ -1,16 +0,0 @@
(function blink(){
const style=getComputedStyle(document.body)
const min=+style.getPropertyValue('--blink-min')
const max=+style.getPropertyValue('--blink-max')
const time=+style.getPropertyValue('--blink-time')
console.debug(min,max,time)
for(const element of document.querySelectorAll('*')){
element.classList.add('blink')
}
setTimeout(()=>{
for(const element of document.querySelectorAll('*')){
element.classList.remove('blink')
}
},time*1000)
setTimeout(blink, (Math.random() * (max - min) + min)*1000)
})()
-27
View File
@@ -1,27 +0,0 @@
@import url('basic.css');
img {
--bounce-height: 5%;
height: calc(100% - var(--bounce-height));
bottom: 0%;
}
.active {
animation: bounce 0.5s;
}
@keyframes bounce {
0% {
bottom: 0%;
animation-timing-function: ease-out;
}
50% {
bottom: var(--bounce-height);
animation-timing-function: ease-in;
}
100% {
bottom: 0%;
}
}
-5
View File
@@ -1,5 +0,0 @@
@import url('basic.css');
#idle, #idle-blink {
filter: brightness(50%);
}
-28
View File
@@ -1,28 +0,0 @@
<!DOCTYPE html>
<div>In this repo there are a few basic css files you can use as starting points.</div>
<div>PNGTube uses the name of each image for its id (the # items in css) and applies the 'active' class (the . items in css) when audio is detected.</div>
<div>You can replicate this behavior in the test links by clicking on the window to toggle the active class.</div>
<a href="basic.css" download>basic.css</a>
<a href="test.html?css=basic.css" target="_blank">test</a>
<span>All other files are based on this one, which changes the opacity of the 'active' and 'idle' images based on voice activity</span>
<br>
<a href="brightness.css" download>brightness.css</a>
<a href="test.html?css=brightness.css" target="_blank">test</a>
<span>This file lowers the brightness of the idle image</span>
<br>
<a href="bounce.css" download>bounce.css</a>
<a href="test.html?css=bounce.css" target="_blank">test</a>
<span>This file adds a bounce animation when speaking starts</span>
<br>
<a href="blink.css" download>blink.css</a>
<a href="test.html?css=blink.css" target="_blank">test</a>
<span>This file adds a blink animation, and requires active-blink and idle-blink images.</span>
<br>
<a href="reactive.css" download>reactive.ss</a>
<a href="test.html?css=reactive.css" target="_blank">test</a>
<span>This file simply combines the brightness and bounce files to replicate the <a href="https://reactive.fugi.tech/" target="_blank">Reactive by FugiTech</a> style.</span>
<br>
<a href="hide.css" doanload>hide.css</a>
<a href="test.html?css=hide.css" target="_blank">test</a>
<span>This file hides all images below the viewport during silence</span>
<br>
-15
View File
@@ -1,15 +0,0 @@
@import url('basic.css');
img {
bottom:-100%;
transition-property: bottom;
transition-duration: 0.5s;
transition-delay: 1s;
transition-timing-function: ease-in;
}
img.active{
bottom:0%;
transition-delay: 0s;
transition-timing-function: ease-out;
}
-566
View File
@@ -1,566 +0,0 @@
<!DOCTYPE html>
<body>
<div id="status">Loading...</div>
<span id="connectionSettings" hidden>
<table>
<tr>
<td>Websocket URL:</td>
<td><input id="url" type="url"></td>
</tr>
<tr>
<td>Websocket Password:</td>
<td><input id="password" type="password"></td>
</tr>
</table>
<button id="connect" onclick="connect()">Connect</button>
<button id="useQR" onclick="useQrCode()">Use QR Code</button>
<br>
<span id="qr" hidden>
<video id="qrvideo" hidden></video>
<br>
<span>Make sure this device and the one running OBS are on the same network.</span>
<br>
<button onclick="screenCapture()">Capture from Screen</button>
<button onclick="cameraCapture()">Capture from Camera</button>
<br>
</span>
</span>
<span id="connected">
<div id="upgradePrompt" class="bordered" hidden>
<span id="name"></span>
<span> is using another version of PNGTube. Switch to this version?</span>
<button onclick="upgrade(this.parentElement.id)">Switch</button>
<button onclick="remove(this.parentElement.id)">Remove</button>
</div>
<span id="upgrades"></span>
<div id="settings" class="bordered" hidden>
<div id="name"></div>
<table>
<tr>
<td>Images</td>
<td>
<input type="file" accept="image/*" multiple onchange="addImage(this)">
<ul id="images">
<li hidden id="imageTemplate">
<button onclick="this.parentElement.remove()">Remove</button>
<span id="name"></span>
<img hidden>
</li>
</ul>
</td>
</tr>
<tr id="sources">
<td>Audio Sources</td>
<td>
<select data-id="source"></select>
<button onclick="addSource(this.parentElement)">Add</button>
<ul>
<li hidden>
<button onclick="this.parentElement.remove()">Remove</button>
<button onclick="addGate(this.parentElement)" hidden id="noiseGate">Add Noise Gate</button>
<label data-id="source"></label>
</li>
</ul>
</td>
</tr>
<tr>
<td>CSS</td>
<td>
<input id="css" type="file" accept="text/css" onchange="addCSS('#name')">
</td>
</tr>
</table>
<button onclick="save(this.parentElement.id)">Save and Apply</button>
<button onclick="remove(this.parentElement.id)">Remove</button>
</div>
<span id="inputSettings"></span>
<span id="newInput">
<table>
<tr>
<td>Scene:</td>
<td><select id="scene"></select></td>
</tr>
<tr>
<td>Name:</td>
<td><input type="text" id="newName"></td>
</tr>
</table>
<button onclick="addNew()">Add new PNGTube</button>
</span>
</span>
</body>
<style>
.bordered {
border: 1px solid black;
}
video {
max-width: 100vw;
max-height: 50vh;
}
</style>
<script
src="https://cdn.jsdelivr.net/npm/obs-websocket-js@5.0.1/dist/obs-ws.min.js"
integrity="sha256-UbkntZrYTokZf8tldcF9nmOn5CNgcmDP+MNsUcKUpEE="
crossorigin="anonymous">
</script>
<script
src="https://cdn.jsdelivr.net/npm/qr-scanner@1.4.1/qr-scanner.umd.min.js"
integrity="sha256-jstRoCLjzwXwLN2o0DMkkwRHtqYCQMxrRkktAHwZekU="
crossorigin="anonymous"></script>
<script>
'use strict'
console.info('loading settings or defaults')
document.querySelector('#url').value=localStorage.getItem('url') || 'ws://localhost:4455'
document.querySelector('#password').value=localStorage.getItem('password') || ''
let browser_source
console.info('initializing obs-websocket-js')
const obs = new OBSWebSocket()
obs.addListener('ConnectionClosed',function onConnectionClosed(error){
document.querySelector('#connected').hidden=true
document.querySelector('#connectionSettings').hidden=false
switch(error.code){
case 1006:
document.querySelector('#status').innerHTML='obs websocket not found.<br>'+
'Is <a href="https://github.com/obsproject/obs-websocket/releases/latest">obs-websocket</a> installed?<br>'+
'Is obs open? Check your websocket settings in OBS under the Tools menu.'
break
case 4009:
document.querySelector('#status').innerHTML=error.message.split(' ').slice(-3).join(' ').toLowerCase().replace('authentication','Password')+
'<br>You can view or change your websocket password in OBS under the Tools menu.'
break
default:
document.querySelector('#status').innerHTML='connection closed: Error '+error.code+': '+error.message
break
}
console.warn('connection closed', error.code, error.message)
document.querySelector('#upgrades').innerHTML=''
document.querySelector('#inputSettings').innerHTML=''
document.querySelector('select').innerHTML=''
})
obs.addListener('ConnectionOpened',function onConnectionOpened(){
console.info('connected to socket, saving url')
localStorage.setItem('url',document.querySelector('#url').value)
document.querySelector('#connectionSettings').hidden=true
})
obs.addListener('Identified',async function onIdentified(){
console.info('authentication passed, saving password')
localStorage.setItem('password',document.querySelector('#password').value)
const video=document.querySelector('#qrvideo')
if(video.srcObject!=null){
video.srcObject.getTracks().forEach(function stopCapture(track){
track.stop()
})
video.srcObject=null
}
document.querySelector('#status').innerHTML='Loading OBS data...'
const loadingPromises=[]
console.info('getting scene list')
loadingPromises.push(obs.call('GetSceneList',{}).then(function onReceiveSceneList(response){
console.info('got scene list:',response)
const scenes=document.querySelector('#scene')
for(const scene of response.scenes){
const option=document.createElement('option')
option.value=scene.sceneName
option.innerHTML=scene.sceneName
scenes.appendChild(option)
}
}))
console.info('getting browser source type name')
loadingPromises.push(obs.call('GetInputKindList',{unversioned:true}).then(function onGetInputKindList(response){
for(const name of response.inputKinds){
if(name.includes('browser')){
browser_source=name
console.debug('browser_source='+name)
return true
}
}
console.warn('browser source type not found, defaulting to browser_source')
browser_source='browser_source'
}).then(()=>{
console.info('getting input list')
obs.call('GetInputList',{}).then(function onReceiveBrowserInputList(response){
console.info('got input list:',response)
const inputPromises=[]
console.info('getting input settings')
for(const input of response.inputs){
inputPromises.push(obs.call('GetInputSettings',{inputName:input.inputName}).then(function onReceiveBrowserInputSettings(response){
Object.assign(input,response)
checkInput(input)
return input
}).catch(function onParseError(error){
console.error(input.inputName,error)
}))
}
loadingPromises.push(Promise.all(inputPromises).then(function onBrowserInputsFiltered(data){
console.info('got input settings:',data)
}))
})
}))
while(loadingPromises.length!=0){
await loadingPromises.shift()
}
console.info('loading finished')
document.querySelector('#status').innerHTML='Ready'
document.querySelector('#connected').hidden=false
})
obs.addListener('InputCreated',checkInput)
obs.addListener('InputRemoved',onInputRemoved)
obs.addListener('InputNameChanged',function onInputNameChanged(input){
console.info('input name changed',input)
const element=document.querySelector('#'+input.oldInputName)
element.id=input.inputName
element.querySelector('#name').innerHTML=input.inputName
})
console.info('connecting to obs')
connect()
function log(...data){
console.log(data)
}
async function connect(){
document.querySelector('#status').innerHTML='Connecting to OBS...'
try{
await obs.connect(
document.querySelector('#url').value,
document.querySelector('#password').value
)
}catch(e){
return false
}
return true
}
function useQrCode(){
document.querySelector('#qr').hidden=false
}
let interval
function startQr(videoSource){
const video=document.querySelector('#qrvideo')
video.srcObject=videoSource
video.play()
video.hidden=false
interval=setInterval(qrScan,1000)
}
async function screenCapture(){
const mediaSource=await navigator.mediaDevices.getDisplayMedia()
startQr(mediaSource)
}
async function cameraCapture(){
const videoSource=await navigator.mediaDevices.getUserMedia({video:true})
startQr(videoSource)
}
function qrScan(){
const video=document.querySelector('#qrvideo')
QrScanner.scanImage(video,{returnDetailedScanResult:true})
.then(onQrScan).catch(function(error){console.warn(error)})
}
async function onQrScan(result){
var data=result.data.split('/')
if(data.shift()!='obsws:'){
return false
}
clearInterval(interval)
data.shift()
const fullurl='ws://'+data.shift()
const port=fullurl.split(':').pop()
const localurl='ws://localhost:'+port
const password=data.shift()
const obsurl=document.querySelector('#url')
const obspassword=document.querySelector('#password')
obspassword.value=password
obsurl.value=localurl
if(await connect()){
return true
}
obsurl.value=fullurl
if(await connect()){
return true
}
interval=setInterval(qrScan,1000)
return false
}
function getOverlayURL(name=''){
const length=window.location.href.lastIndexOf('/')
const url=new URL(window.location.href.substring(0,length+1)+'overlay.html')
const searchParams=url.searchParams
const obsurl=document.querySelector('#url')
const obspassword=document.querySelector('#password')
searchParams.set('name',name)
searchParams.set('obsurl',obsurl.value)
if(obspassword.value){
searchParams.set('obspassword',obspassword.value)
}
return url.href
}
async function setURL(name,currentURL=getOverlayURL()){
const url=new URL(currentURL)
const searchParams=url.searchParams
const obsurl=document.querySelector('#url')
const obspassword=document.querySelector('#password')
if(searchParams.get('name')!=name){
searchParams.set('name',name)
}
if(searchParams.get('obsurl')!=obsurl.value){
searchParams.set('obsurl',obsurl.value)
}
if(searchParams.get('obspassword')!=obspassword.value){
searchParams.set('obspassword',obspassword.value)
}
if(currentURL!=url.href){
console.log('updating url for '+name)
obs.call('SetInputSettings',{
inputName:name,
inputSettings:{url:url.href}
})
}
}
function addLegacy(name){
console.info('adding upgrade prompt for '+name)
const upgradePrompt=document.querySelector('#upgradePrompt').cloneNode(true)
upgradePrompt.hidden=false
upgradePrompt.querySelector('#name').innerHTML=name
upgradePrompt.id=name
document.querySelector('#upgrades').appendChild(upgradePrompt)
}
const allSettings={}
function addSettings(name){
console.info('getting settings for',name)
const settings=document.querySelector('#settings').cloneNode(true)
settings.hidden=false
settings.querySelector('#name').innerHTML=name
settings.id=name
const css=settings.querySelector('#css')
css.outerHTML=css.outerHTML.replace('#name',name)
obs.call('GetPersistentData',{
realm:'OBS_WEBSOCKET_DATA_REALM_GLOBAL',
slotName:'pngtubev6'+name
}).then(function(data){
data=data.slotValue
console.log('got settings for',name,data)
if(data==null){
data={
image:{},
source:[],
css:''
}
}
allSettings[name]=data
for(const source of data.source){
addSource(settings.querySelector('#sources'),source)
}
})
document.querySelector('#inputSettings').appendChild(settings)
}
function upgrade(name){
document.querySelector('#'+name).remove()
setURL(name)
addSettings(name)
}
function remove(name){
const input={inputName:name}
obs.call('RemoveInput',input).then(()=>onInputRemoved(input))
}
function addNew(){
const scene=document.querySelector('#scene').value
const name=document.querySelector('#newName').value
obs.call('CreateInput',{
sceneName:scene,
inputName:name,
inputKind:browser_source,
inputSettings:{
url:getOverlayURL(name)
}
}).catch(function onCreateInputError(error){
document.querySelector('#status').innerHTML='Adding PNGTube failed: Error '+error.code+': '+error.message
})
}
function checkInput(input){
const option=document.createElement('option')
option.value=input.inputName
option.innerHTML=input.inputName
for(const select of document.querySelectorAll('select[data-id=source]')){
select.appendChild(option)
}
if(input.inputKind!=browser_source){
return
}
if('url' in input.inputSettings === false){
return
}
if(input.inputSettings.url.startsWith(window.location.href)){
setURL(input.inputName,input.inputSettings.url)
addSettings(input.inputName)
return
}
if(input.inputSettings.url.startsWith('https://sugoidogo.github.io/pngtube')){
addLegacy(input.inputName)
}
if(input.inputSettings.url.startsWith(window.location.href.split('/').slice(0,-2).join('/'))){
addLegacy(input.inputName)
}
}
function genericError(error,prefix=''){
console.log(error)
document.querySelector('#status').innerHTML=prefix+'Error '+error.code+': '+error.message
}
function onInputRemoved(input){
console.info('input removed',input)
document.querySelector('#'+input.inputName).remove()
for(const option of document.querySelectorAll('option[value='+input.inputName+']')){
option.remove()
}
obs.call('SetPersistentData',{
realm:'OBS_WEBSOCKET_DATA_REALM_GLOBAL',
slotName:'pngtubev5'+input.inputName,
slotValue:{}
})
}
function save(name){
const settings=document.querySelector('#'+name)
const data=allSettings[name]
const sources=[]
for(const source of settings.querySelectorAll('label[data-id=source]')){
if(source.innerHTML){
sources.push(source.innerHTML)
}
}
if(sources.length!=0){
data.source=sources
}
const images={}
for(const image of settings.querySelectorAll('#imageTemplate')){
const img=image.querySelector('img')
console.log(img)
if(img.src){
const name=image.querySelector('#name')
console.log(name.innerHTML)
images[name.innerHTML]=img.src
}
}
if(Object.keys(images).length>0){
data.image=images
}
console.log('saving data:',data)
obs.call('SetPersistentData',{
realm:'OBS_WEBSOCKET_DATA_REALM_GLOBAL',
slotName:'pngtubev6'+name,
slotValue:data
})
let width=0
let height=0
let imagePromises=[]
for(const [name,source] of Object.entries(data.image)){
imagePromises.push(new Promise(function(resolve){
const image=new Image();
image.onload=function(){
if(image.naturalWidth>width){
width=image.naturalWidth
}
if(image.naturalHeight>height){
height=image.naturalHeight
}
resolve()
}
image.src=source
}))
}
Promise.all(imagePromises).then(function(){
obs.call('SetInputSettings',{
inputName:name,
inputSettings:{url:''}
}).then(function(){
obs.call('SetInputSettings',{
inputName:name,
inputSettings:{
url:getOverlayURL(name),
width:width,
height:height
}
})
})
})
}
function addSource(parentElement,value=null){
if(value==null){
value=parentElement.querySelector('select').value
}
const listItem=parentElement.querySelector('li').cloneNode(true)
listItem.hidden=false
listItem.querySelector('label').innerHTML=value
parentElement.querySelector('ul').appendChild(listItem)
obs.call('GetSourceFilterList',{
sourceName:value
}).then(function(data){
for(const filter of data.filters){
if(filter.filterKind=='noise_gate_filter'){
return
}
}
listItem.querySelector('#noiseGate').hidden=false
})
}
function addGate(parentElement){
const name=parentElement.querySelector('label').innerHTML
obs.call('CreateSourceFilter',{
sourceName:name,
filterName:'Noise Gate',
filterKind:'noise_gate_filter',
filterSettings:{
attack_time:25,
release_time:50,
close_threshold:-60,
open_threshold:-55
}
}).then(function(){
parentElement.querySelector('#noiseGate').hidden=true
obs.call('OpenInputFiltersDialog',{inputName:name})
})
}
function addImage(filePicker){
for(const file of filePicker.files){
const listItem=document.querySelector('#imageTemplate').cloneNode(true)
listItem.hidden=false
listItem.querySelector('#name').textContent=file.name.split('.').shift()
const fileReader=new FileReader()
fileReader.onload=function(){
listItem.querySelector('img').src=fileReader.result
filePicker.parentElement.querySelector('#images').appendChild(listItem)
}
fileReader.readAsDataURL(file)
}
}
function addCSS(name){
const fileReader=new FileReader()
fileReader.onload=function(){
allSettings[name].css=fileReader.result
}
fileReader.readAsText(document.querySelector('#'+name).querySelector('#css').files[0])
}
</script>
-120
View File
@@ -1,120 +0,0 @@
<!DOCTYPE html>
<script
src="https://cdn.jsdelivr.net/npm/obs-websocket-js@5.0.1/dist/obs-ws.min.js"
integrity="sha256-UbkntZrYTokZf8tldcF9nmOn5CNgcmDP+MNsUcKUpEE="
crossorigin="anonymous">
</script>
<script type="module" src="https://sugoidogo.github.io/js-util/CSSRandom.js"></script>
<script>
const obs=new OBSWebSocket()
const params=new URLSearchParams(window.location.search)
const obsurl=params.get('obsurl')
const obspassword=params.get('obspassword')
const name=params.get('name')
const inputs=[]
let backoff=false
let active=false
function backoffStart(event){
if(event instanceof AnimationEvent && getComputedStyle(event.target,event.pseudoElement).animationIterationCount=='infinite'){
return
}
backoff=true
}
function backoffEnd(){
backoff=false
}
window.ontransitionstart=backoffStart
window.ontransitionend=backoffEnd
window.ontransitioncancel=backoffEnd
window.onanimationstart=backoffStart
window.onanimationend=backoffEnd
window.ontransitioncancel=backoffEnd
obs.addListener('Identified',function onIdentified(){
obs.call('GetPersistentData',{
realm:'OBS_WEBSOCKET_DATA_REALM_GLOBAL',
slotName:'pngtubev6'+name
}).then(function onGetPersistentData(data){
data=data.slotValue
console.log(data)
const style=document.createElement('style')
style.innerHTML=data.css
const body=document.querySelector('body')
body.appendChild(style)
for(const [id,image] of Object.entries(data.image)){
const img=document.createElement('img')
img.id=id
img.src=image
body.appendChild(img)
}
for(const input of data.source){
inputs.push(input)
}
obs.on('InputVolumeMeters',function onInputVolumeMeters(data){
for(const input of data.inputs){
if(inputs.includes(input.inputName)){
for(const channel of input.inputLevelsMul){
for(const value of channel){
if(value!=0){
active=true
return
}
}
}
}
}
active=false
})
})
})
obs.addListener('InputNameChanged',function onInputNameChanged(input){
console.log(input)
obs.call('GetPersistentData',{
realm:'OBS_WEBSOCKET_DATA_REALM_GLOBAL',
slotName:'pngtubev6'+input.oldInputName
}).then(function(data){
obs.call('SetPersistentData',{
realm:'OBS_WEBSOCKET_DATA_REALM_GLOBAL',
slotName:'pngtubev6'+input.oldInputName,
slotValue:{
image:{},
source:[],
css:''
}
})
obs.call('SetPersistentData',{
realm:'OBS_WEBSOCKET_DATA_REALM_GLOBAL',
slotName:'pngtubev6'+input.inputName,
slotValue:data.slotValue
}).then(function(){
const url=new URL(window.location.href)
const searchParams=url.searchParams
searchParams.set('name',input.inputName)
obs.call('SetInputSettings',{
inputName:input.inputName,
inputSettings:{url:url.href}
})
})
})
})
window.onload=async function(){
obs.connect(obsurl,obspassword,{eventSubscriptions:(1 << 16)+(1 << 3)})
requestAnimationFrame(function animate(){
if(!backoff){
for(const img of document.querySelectorAll('img')){
if(active){
img.classList.add('active')
}else{
img.classList.remove('active')
}
}
}
requestAnimationFrame(animate)
})
}
</script>
-3
View File
@@ -1,3 +0,0 @@
@import url('brightness.css');
@import url('bounce.css');
@import url('blink.css');
-51
View File
@@ -1,51 +0,0 @@
<img id="active" src="../tubie/active.png">
<img id="active-blink" src="../tubie/active-blink.png">
<img id="idle" src="../tubie/idle.png">
<img id="idle-blink" src="../tubie/idle-blink.png">
<script>
const csslink=new URLSearchParams(window.location.search).get('css')
const css=document.createElement('link')
css.rel='stylesheet'
css.href=csslink
document.head.appendChild(css)
let backoff=false
let idle=false
window.onclick=async function(){
if(backoff){
if(!idle){
idle=true
}
return
}
for(const img of document.querySelectorAll('img')){
img.classList.toggle('active')
}
}
function backoffStart(event){
if(event instanceof AnimationEvent && getComputedStyle(event.target,event.pseudoElement).animationIterationCount=='infinite'){
return
}
backoff=true
}
function backoffEnd(){
backoff=false
if(idle){
for(const img of document.querySelectorAll('img')){
img.classList.remove('active')
}
}
}
window.ontransitionstart=backoffStart
window.ontransitionend=backoffEnd
window.ontransitioncancel=backoffEnd
window.onanimationstart=backoffStart
window.onanimationend=backoffEnd
window.ontransitioncancel=backoffEnd
import('./blink.js')
</script>
-465
View File
@@ -1,465 +0,0 @@
<h1>PNGTube</h1>
<select id="instance" hidden></select>
<form name="new_instance" hidden>
<input type="text" name="instance_name" required>
<button type="submit">Create new instance</button>
</form>
<form name="instance_config" hidden>
<button type="button" id="remove_instance">Delete</button>
<br>
<table>
<tr>
<td>
<h2>Assets</h2>
<div id="asset_warning" hidden>
Possible missing assets:
<ul id="missing_assets"></ul>
Make sure all your assets have been added,
<br>
and their id's are set correctly below.
<br>
If you have already done so, ignore this warning.
<br><br>
</div>
<input type="file" id="asset_file_input" multiple>
<ul id="asset_list"></ul>
</td>
<td>
<h2>Activation</h2>
<div id="obs_config">
<span id="obs_status">Connecting to OBS...</span>
<input type="hidden" name="obs_token">
<button type="button" id="obs_auth">Get OBS Authorization</button>
</div>
<br>
<button type="button" id="add_source">Add Source</button>
<ul id="source_list"></ul>
</td>
<td id="instructions">
<h3>Adjusting Sensitivity</h3>
<table>
<thead>
<tr>
<td colspan="2">Noise Gate Settings</td>
</tr>
</thead>
<tr>
<td>Close Threshold</td>
<td>Idle volume level</td>
</tr>
<tr>
<td>Open Threshold</td>
<td>Active volume level</td>
</tr>
<tr>
<td>Hold Time</td>
<td>How long to hold before switching back to idle</td>
</tr>
</table>
<br>
PNGTube will consider your audio sources 'active'
if there is any sound coming through at all.
The volume meters in OBS won't show sounds quieter than -60db,
so even if you think it's silent,
most audio devices have some static noise in them.
The "Adjust Sensitivity" button on the left will open the filter panel,
and add a noise gate if you don't have one already.
</td>
</tr>
</table>
<div id="status">Loading...</div>
<button type="submit">Save & Copy URL</button>
</form>
<datalist id="element_references"></datalist>
<datalist id="obs_sources"></datalist>
<span id="templates" hidden>
<li class="asset">
<form name="assets">
<button type="button" onclick="this.parentElement.parentElement.remove()">remove</button>
<input type="search" name="id" required pattern="[\w-]+" list="element_references">
<span id="filename"></span>
<input type="hidden" name="url">
</form>
</li>
<li class="source">
<button type="button" onclick="this.parentElement.remove()">remove</button>
<input type="search" name="sources" list="obs_sources" required pattern=".+">
<br>
<button type="button" class="sensitivity">Adjust Sensitivity</button>
</li>
</span>
<style>
* {
font-family: Inter, Roobert, Helvetica Neue, Helvetica, Arial, sans-serif;
}
form[name=new_instance]:not([hidden]),
form[name=instance_config]:not([hidden]){
display: inline;
}
td {
border: 1px solid lightgray;
vertical-align: top;
white-space: nowrap;
width: fit-content;
}
#instructions {
white-space: normal;
width: 20em;
}
input:invalid:required {
background-color: lightpink;
}
button#add_source,
button.sensitivity {
width: 100%;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/localforage@1.10.0/dist/localforage.js"
integrity="sha256-6fEA3uF0XCtisIcFZy5HTT58XocXY7LX7JUfEiK6W5Y="
crossorigin="anonymous">
</script>
<script src="https://cdn.jsdelivr.net/npm/obs-websocket-js@5.0.2/dist/obs-ws.js"
integrity="sha256-WK517pkeDj2ugtVH+GZa/hQqIoNWNMcBk241/vo1mBw="
crossorigin="anonymous">
</script>
<script type="module">
import FileReaderAsync from '../js-util/FileReaderAsync.mjs'
import {getFormDataDeep} from '../js-util/FormDataDeep.mjs'
/** @type {Storage} */
const localStorage=localforage.createInstance({name:'pngtube.v7'})
const obs=new OBSWebSocket()
/** @type {HTMLDivElement} */
const status_div=document.querySelector('div#status')
/** @type {HTMLSelectElement} */
const instance_select=document.querySelector('select#instance')
/** @type {HTMLFormElement} */
const new_instance_form=document.querySelector('form[name=new_instance]')
/** @type {HTMLFormElement} */
const instance_config_form=document.querySelector('form[name=instance_config')
/** @type {HTMLInputElement} */
const tts_checkbox=document.querySelector('input[name=tts]')
/** @type {HTMLInputElement} */
const source_input=document.querySelector('input#sources')
/** @type {HTMLUListElement} */
const source_list=document.querySelector('ul#source_list')
/** @type {HTMLUListElement} */
const asset_list=document.querySelector('ul#asset_list')
/** @type {HTMLInputElement} */
const asset_file_input=document.querySelector('input#asset_file_input')
/** @type {HTMLSpanElement} */
const obs_status_span=document.querySelector('span#obs_status')
/** @type {HTMLInputElement} */
const obs_token_input=document.querySelector('input[name=obs_token]')
/** @type {HTMLDataListElement} */
const obs_sources_datalist=document.querySelector('datalist#obs_sources')
/** @type {HTMLSpanElement} */
const templates=document.querySelector('span#templates')
function set_loading(loading){
if(loading){
status_div.innerHTML='Loading...'
status_div.hidden=false
instance_select.hidden=true
new_instance_form.hidden=true
instance_config_form.hidden=true
}else{
instance_select.hidden=false
status_div.hidden=true
}
}
async function updateReferences(){
let refs=['idle','active']
for(const asset_list_item of asset_list.children){
const asset_url_input=asset_list_item.querySelector('input[name=url]')
const asset_dataurl=asset_url_input.value
if(asset_dataurl.startsWith('data:text')){
const asset_text=await fetch(asset_dataurl,{cache:'no-cache'}).then(response=>response.text())
const asset_references=asset_text.matchAll(/#[\w-]+/g)
for(const asset_reference of asset_references){
refs.push(asset_reference[0].substring(1))
}
}
}
refs=[...new Set(refs)]
/** @type {HTMLDataListElement} */
const element_references_datalist=document.querySelector('datalist#element_references')
while(element_references_datalist.childElementCount>0){
element_references_datalist.firstElementChild.remove()
}
for(const ref of refs){
const option=document.createElement('option')
option.value=ref
element_references_datalist.appendChild(option)
}
for(const asset_list_item of asset_list.children){
const asset_id_input=asset_list_item.querySelector('input[name=id]')
const asset_id=asset_id_input.value
if(refs.includes(asset_id)){
refs.splice(refs.indexOf(asset_id),1)
}
}
const asset_warning_div=document.querySelector('div#asset_warning')
if(refs.length!=0){
const missing_assets_list=asset_warning_div.querySelector('ul#missing_assets')
while(missing_assets_list.childElementCount>0){
missing_assets_list.firstElementChild.remove()
}
for(const ref of refs){
const listItem=document.createElement('li')
listItem.innerHTML=ref
missing_assets_list.appendChild(listItem)
}
asset_warning_div.hidden=false
}else{
asset_warning_div.hidden=true
}
}
/**
* @param {File|String} source
* @param {String} id
*/
async function add_asset(source,id=undefined){
const asset_list_item=templates.querySelector('li.asset').cloneNode(true)
const asset_id=asset_list_item.querySelector('input[name=id]')
const asset_file_name=asset_list_item.querySelector('span#filename')
const asset_url=asset_list_item.querySelector('input[name=url]')
const asset_remove_button=asset_list_item.querySelector('button')
asset_remove_button.addEventListener('click',updateReferences)
if(! (source instanceof File)){
let filename=null
if(!source.startsWith('data:')){
filename=source.split('/').at(-1)
}
source=await fetch(source,{cache:'no-cache'}).then(response=>response.blob())
if(!filename){
filename=id+'.'+source.type.split('/')[1]
}
source=new File([source],filename,{type:source.type})
}
asset_file_name.innerHTML=source.name
asset_url.value=await FileReaderAsync.readAs('DataURL',source)
if(id){
asset_id.value=id
}else{
asset_id.value=source.name.split('.')[0]
}
asset_id.oninput=updateReferences
asset_list.appendChild(asset_list_item)
updateReferences()
}
/** @param {HTMLInputElement} source_input */
async function adjust_sensitivity(source_input){
obs.call('GetSourceFilterList',{sourceName:source_input.value})
.then((response)=>{
for(const filter of response.filters){
if(filter.filterKind='noise_gate_filter'){
obs.call('OpenInputFiltersDialog',{inputName:source_input.value})
return
}
}
obs.call('CreateSourceFilter',{
sourceName:source_input.value,
filterName:'Noise Gate for PNGTube',
filterKind:'noise_gate_filter',
filterSettings:{
close_threshold:-60,
open_threshold:-30
}
}).then((response)=>{
obs.call('OpenInputFiltersDialog',{inputName:source_input.value})
})
})
}
async function add_source(name='Mic/Aux'){
const source_list_item=templates.querySelector('li.source').cloneNode(true)
const source_input=source_list_item.querySelector('input[name=sources]')
const source_sensitivity_button=source_list_item.querySelector('button.sensitivity')
source_input.value=name
source_sensitivity_button.onclick=()=>adjust_sensitivity(source_input)
source_list.appendChild(source_list_item)
}
document.querySelector('button#add_source').onclick=()=>add_source()
async function load_config(instance_name=instance_select.value){
set_loading(true)
obs.disconnect()
for(const list of [asset_list,source_list]){
while(list.childElementCount>0){
list.firstElementChild.remove()
}
}
if(instance_name==''){
set_loading(false)
new_instance_form.hidden=false
return false
}
const loading_promises=[]
const config=await localStorage.getItem(instance_name) || await fetch('default.json',{cache:'no-cache'}).then(response=>response.json())
console.debug(config)
if(config.obs_token){
obs_token_input.value=config.obs_token
obs_init()
}
if(config.assets){
if(! config.assets instanceof Array){
config.assets=[config.assets]
}
for(const asset of config.assets){
loading_promises.push(add_asset(asset.url,asset.id))
}
}
if(config.sources){
if(typeof config.sources=='string'){
config.sources=[config.sources]
}
for(const source of config.sources){
loading_promises.push(add_source(source))
}
}
return Promise.allSettled(loading_promises).then(()=>{
set_loading(false)
instance_config_form.hidden=false
return config
})
}
instance_select.onchange=()=>load_config()
async function instance_select_init(){
while(instance_select.childElementCount>0){
instance_select.firstElementChild.remove()
}
for(const instance_name of await localStorage.keys()){
const option=document.createElement('option')
option.value=instance_name
option.innerHTML=instance_name
instance_select.appendChild(option)
}
const option=document.createElement('option')
option.innerHTML='Create New Instance'
option.value=''
instance_select.appendChild(option)
instance_select.hidden=false
}
async function add_instance(name){
const option=document.createElement('option')
option.value=name
option.innerHTML=name
instance_select.prepend(option)
instance_select.value=name
load_config()
}
new_instance_form.onsubmit=(event)=>{
event.preventDefault()
add_instance(new_instance_form.instance_name.value)
new_instance_form.instance_name.value=''
}
asset_file_input.onchange=(event)=>{
for(const file of asset_file_input.files){
add_asset(file)
}
asset_file_input.value=''
}
obs.on('ConnectionClosed',(error)=>{
obs_status_span.innerHTML='Not connected to OBS'
console.error(error)
})
obs.on('Identified',()=>{
obs_status_span.innerHTML='Connected to OBS'
obs.call('GetInputList')
.then((response)=>{
for(const input of response.inputs){
const option=document.createElement('option')
option.value=input.inputName
obs_sources_datalist.append(option)
}
})
})
obs.on('InputCreated',(event)=>{
console.debug('source created: '+event.inputName)
const option=document.createElement('option')
option.value=event.inputName
obs_sources_datalist.append(option)
})
obs.on('InputRemoved',(event)=>{
console.debug('source removed: '+event.inputName)
document.querySelector("option[value='"+event.inputName+"']").remove()
})
obs.on('InputNameChanged',(event)=>{
console.debug('source renamed :'+event.oldInputName+" > "+event.inputName)
document.querySelector("option[value='"+event.oldInputName+"']").value=event.inputName
})
async function obs_init(){
let obsurl
let obspassword
const search_params=new URLSearchParams(location.search)
if(search_params.has('obsurl')){
obsurl=search_params.get('obsurl')
obspassword=search_params.get('obspassword')
const url=new URL(obsurl)
url.password=obspassword
obs_token_input.value=url.href
}else{
if(obs_token_input.value){
const url=new URL(obs_token_input.value)
obspassword=url.password
url.password=''
obsurl=url.href
}else{
obs_status_span.innerHTML='No OBS Authorization found'
}
}
return obs.connect(obsurl,obspassword)
}
async function obs_auth(){
const url=new URL('https://sugoidogo.github.io/obsconnect')
url.searchParams.append('redirect_uri',location.href)
location.assign(url)
}
document.querySelector('button#obs_auth').onclick=obs_auth
instance_config_form.onsubmit=(event)=>{
event.preventDefault()
const config=getFormDataDeep(instance_config_form)
console.debug(config)
localStorage.setItem(instance_select.value,config)
const url=new URL(location.origin+location.pathname)
if(url.pathname.endsWith('/')){
url.pathname+='overlay.html'
}else{
const path=url.pathname.split('/')
path.pop()
path.push('overlay.html')
url.pathname=path.join('/')
}
url.searchParams.append('name',instance_select.value)
url.hash=JSON.stringify(config)
if(url.href.length>30000){
status_div.innerHTML="Too much data! That url probably won't work unless you use this page as a custom browser dock in OBS."
status_div.hidden=false
}
navigator.clipboard.writeText(url.href)
.catch(function(){
url.hash=''
status_div.innerHTML='Auto Copy Failed! Please manually copy the following url:<br>'
+url.href+'<br>'
status_div.hidden=false
})
}
async function remove_instance(name=instance_select.value){
localStorage.removeItem(name)
.then(instance_select_init)
.then(load_config)
}
document.querySelector('button#remove_instance').onclick=()=>remove_instance()
instance_select_init()
.then(load_config)
</script>
-114
View File
@@ -1,114 +0,0 @@
<style>
body {
display: flex;
justify-content: center;
align-items: center;
}
img {
position: absolute;
bottom: 0%;
width: 100%;
height: 100%;
object-fit: contain;
opacity: 0;
}
#idle:not(.active) {
opacity: 1;
}
#active.active {
opacity: 1;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/localforage@1.10.0/dist/localforage.min.js"
integrity="sha256-zBaNlfuSfUaxBDcmz+E5mOCJAv9j8kMw4rsikBCe0UU="
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/obs-websocket-js@5.0.2/dist/obs-ws.min.js"
integrity="sha256-h/nnsIkJJZMNu08xQJBMuUvcrgcUj1YlTYmoA0q4Ep8="
crossorigin="anonymous"></script>
<script type="module">
/** @type {Storage} */
const localStorage = localforage.createInstance({ name: 'pngtube.v7' })
const search_params = new URLSearchParams(location.search)
async function getConfig(){
try {
const config=JSON.parse(decodeURIComponent(location.hash.substring(1)))
localStorage.setItem(search_params.get('name'),config)
return config
} catch(error) {
console.warn(error)
return localStorage.getItem(search_params.get('name'))
}
}
// load config and assets
const config = await getConfig()
console.debug(config)
if (!(config.assets instanceof Array)) {
config.assets = [config.assets]
}
if(typeof config.sources == 'string'){
config.sources=[config.sources]
}
import loadAsset from '../js-util/AssetLoader.mjs'
for (const source of config.assets) {
loadAsset(source.url, source.id, true)
}
// backoff mechanism
let backoff=false
function backoffStart(event){
if(event instanceof AnimationEvent && getComputedStyle(event.target,event.pseudoElement).animationIterationCount=='infinite'){
return
}
backoff=true
}
function backoffEnd(){
backoff=false
}
window.ontransitionstart=backoffStart
window.ontransitionend=backoffEnd
window.ontransitioncancel=backoffEnd
window.onanimationstart=backoffStart
window.onanimationend=backoffEnd
window.ontransitioncancel=backoffEnd
// animation function
let active=false
requestAnimationFrame(function animate(){
if(!backoff){
for(const img of document.querySelectorAll('img')){
if(active){
img.classList.add('active')
}else{
img.classList.remove('active')
}
}
}
requestAnimationFrame(animate)
})
// obs connection
const obs = new OBSWebSocket()
obs.on('ConnectionClosed',(error)=>{
window.alert('PNGTube error '+error.code+': '+error.message)
})
obs.on('Identified',(event)=>{
console.debug(event)
})
obs.on('InputVolumeMeters',(event)=>{
const inputs=event.inputs.filter(input=>config.sources.includes(input.inputName))
for(const input of inputs){
for(const channel of input.inputLevelsMul){
if(channel[0]>0){
active=true
return
}
}
}
active=false
})
const token=new URL(config.obs_token)
const password=token.password
token.password=''
const url=token.href
obs.connect(url,password,{eventSubscriptions:(1 << 16)})
</script>
-30
View File
@@ -1,30 +0,0 @@
<style>
body {
--blink-min: 1;
--blink-max: 10;
--blink-time: 0.1;
}
#active-blink.blink.active,
#idle-blink.blink:not(.active) {
opacity: 1;
}
</style>
<script type="module">
(function blink(){
const style=getComputedStyle(document.body)
const min=+style.getPropertyValue('--blink-min')
const max=+style.getPropertyValue('--blink-max')
const time=+style.getPropertyValue('--blink-time')
console.debug(min,max,time)
for(const element of document.querySelectorAll('*')){
element.classList.add('blink')
}
setTimeout(()=>{
for(const element of document.querySelectorAll('*')){
element.classList.remove('blink')
}
},time*1000)
setTimeout(blink, (Math.random() * (max - min) + min)*1000)
})()
</script>
-25
View File
@@ -1,25 +0,0 @@
img {
--bounce-height: 5%;
height: calc(100% - var(--bounce-height));
bottom: 0%;
}
.active {
animation: bounce 0.5s;
}
@keyframes bounce {
0% {
bottom: 0%;
animation-timing-function: ease-out;
}
50% {
bottom: var(--bounce-height);
animation-timing-function: ease-in;
}
100% {
bottom: 0%;
}
}
-3
View File
@@ -1,3 +0,0 @@
#idle, #idle-blink {
filter: brightness(50%);
}
-20
View File
@@ -1,20 +0,0 @@
{
"assets":[
{
"id":"bounce_animation",
"url":"bounce.css"
},
{
"id":"idle_half_brightness",
"url":"brightness.css"
},
{
"id":"blink_animation",
"url":"blink.html"
}
],
"sources":[
"Mic/Aux"
],
"obs_token":"ws://localhost:4455"
}
-13
View File
@@ -1,13 +0,0 @@
img {
bottom:-100%;
transition-property: bottom;
transition-duration: 0.5s;
transition-delay: 1s;
transition-timing-function: ease-in;
}
img.active{
bottom:0%;
transition-delay: 0s;
transition-timing-function: ease-out;
}
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "pngtube",
"assets": {"directory": "assets"},
"workers_dev": false,
"keep_vars": true,
"compatibility_date": "2025-12-20"
}