23 Commits
Author SHA1 Message Date
sugoidogo 5650f82d77 css update
publish.yaml / release (push) Successful in 32s
2026-07-29 03:42:12 -07:00
sugoidogo bd4628efea Revert "css update"
This reverts commit 3e335e72e3.
2026-07-29 02:06:06 -07:00
sugoidogo e0e6b83162 update obsconnect url
publish.yaml / release (push) Successful in 31s
2026-07-29 00:50:40 -07:00
sugoidogo b088c5bf85 fix wrong assets
publish.yaml / release (push) Successful in 31s
2026-07-28 23:56:54 -07:00
sugoidogo 78e7d06fd5 update defaults to use tubie
publish.yaml / release (push) Successful in 33s
2026-07-28 23:49:24 -07:00
sugoidogo 3e335e72e3 css update 2026-07-28 23:44:49 -07:00
sugoidogo a710dcf130 fix esbuild warning
/ release (push) Successful in 29s
2025-12-20 07:41:19 -08:00
sugoidogo 2134290895 refactor for npm instead of submodule
/ release (push) Successful in 37s
2025-12-20 07:39:13 -08:00
sugoidogo 7cc765ddc4 refactor for new host
/ release (push) Successful in 30s
2025-12-20 07:15:11 -08:00
josephsmendoza 95aa404ecd remove hostname from error tracking for local file 2024-11-08 03:30:53 -08:00
josephsmendoza 488cce9beb add release info to error tracker 2024-11-08 03:19:28 -08:00
josephsmendoza 51038c5e54 warn when OBS connection is missing 2024-10-05 00:43:07 -07:00
josephsmendoza 65d8e3d591 add hostname to sentry init 2024-10-01 00:46:08 -07:00
josephsmendoza a1657fb48c change non-crittical error to warning 2024-09-30 21:06:29 -07:00
josephsmendoza b0b65ff77f add remotejs 2024-09-30 09:46:36 -07:00
josephsmendoza 2260e251d9 remove error popup 2024-09-29 22:48:50 -07:00
SugoiDogoandGitHub d1ee10260b add glitchtip 2024-09-29 22:19:57 -07:00
SugoiDogoandGitHub b574abbc9d add glitchtip 2024-09-29 22:18:40 -07:00
josephsmendoza 756d14b620 v8 release 2023-06-19 13:31:36 -07:00
josephsmendoza be41430c92 show short url when running in OBS 2023-04-15 17:25:18 -07:00
josephsmendoza eaaa3e8667 update readme 2023-01-04 02:37:06 +00:00
josephsmendoza ac2c724069 update url limit 2022-12-20 23:17:49 +00:00
josephsmendoza 7992cdaa6c update hide.css 2022-12-20 23:17:33 +00:00
47 changed files with 2279 additions and 2943 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
+3 -4
View File
@@ -1,4 +1,3 @@
/_*
/*.pid
/*.log
/v4/yarn
node_modules
.wrangler
assets/*.js*
-3
View File
@@ -1,3 +0,0 @@
[submodule "js-util"]
path = js-util
url = git@github.com:sugoidogo/js-util.git
+1
View File
@@ -0,0 +1 @@
@sugoidogo:registry=https://gitea.sugoidogo.com/api/packages/sugoidogo/npm/
+4 -5
View File
@@ -4,9 +4,8 @@ OBS Browser Source for creating an image based avatar that animates to audio inp
1. Open OBS
2. Check your OBS version by reading the window title
- 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)
- 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. Visit the [PNGTube Setup Page](https://sugoidogo.github.io/pngtube2/v6/), which will walk you through the rest.
- If you don't know what to use for the CSS file, check out the [samples](https://sugoidogo.github.io/pngtube2/v6/css)
## Support
Support information for all my software is on my [GitHub profile](https://github.com/sugoidogo)
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.
View File
View File
+16
View File
@@ -1,5 +1,21 @@
{
"assets":[
{
"id":"active",
"url":"tubie/active.png"
},
{
"id": "active-blink",
"url": "tubie/active-blink.png"
},
{
"id": "idle",
"url": "tubie/idle.png"
},
{
"id": "idle-blink",
"url": "tubie/idle-blink.png"
},
{
"id":"bounce_animation",
"url":"bounce.css"
-2
View File
@@ -1,5 +1,3 @@
@import url('basic.css');
img {
bottom:-100%;
transition-property: bottom;
+131
View File
@@ -0,0 +1,131 @@
<head>
<link rel="icon" href="data:image/png;base64,iVBORw0KGgo=">
<link rel="stylesheet" href="https://unpkg.com/sakura.css/css/sakura.css" media="screen" />
<link rel="stylesheet" href="https://unpkg.com/sakura.css/css/sakura-dark.css"
media="screen and (prefers-color-scheme: dark)" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<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 & Download</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>
body {
margin: 0;
}
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;
}
button#add_source,
button.sensitivity {
width: 100%;
}
.source {
margin: 10px;
}
form, input[type=search] {
margin-bottom: 0;
}
</style>
<script type="module" src="index.js"></script>
+24
View File
@@ -0,0 +1,24 @@
<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>

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
+1653
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
{
"scripts": {
"test": "wrangler dev --ip=localhost"
},
"devDependencies": {
"wrangler": "^4.56.0"
},
"dependencies": {
"@sugoidogo/js-util": "^0.2.0",
"localforage": "^1.10.0",
"obs-websocket-js": "^5.0.7"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

+339
View File
@@ -0,0 +1,339 @@
import { FileReaderAsync, getFormDataDeep } from "@sugoidogo/js-util"
import localforage from "localforage"
import OBSWebSocket from "obs-websocket-js/json"
/** @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 })
})
}).catch(error => {
if (error.message = 'Not connected') {
alert('Not connected to OBS')
}
})
}
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.warn(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://obsconnect.sugoidogo.com')
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('/')
}
fetch(url).then(response => response.text())
.then(async html => {
const script = document.createElement('script')
script.type = 'module'
script.innerHTML = 'const config=' + JSON.stringify(config)+'\n'
script.innerHTML+=await fetch(new URL('overlay.js',url)).then(response=>response.text())
html += script.outerHTML
const blob = new Blob([html])
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = 'pngtube-' + instance_select.value + '.html'
a.click()
})
}
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)
+69
View File
@@ -0,0 +1,69 @@
import { loadAsset } from '@sugoidogo/js-util'
import OBSWebSocket from 'obs-websocket-js/json'
console.debug(config)
if (!(config.assets instanceof Array)) {
config.assets = [config.assets]
}
if (typeof config.sources == 'string') {
config.sources = [config.sources]
}
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) })
-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>
-459
View File
@@ -1,459 +0,0 @@
<h1>PNGTube</h1>
<div id="status">Loading...</div>
<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>
<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>500000){
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)
}
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>
+12
View File
@@ -0,0 +1,12 @@
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "pngtube",
"assets": {"directory": "assets"},
"workers_dev": false,
"keep_vars": true,
"compatibility_date": "2025-12-20",
"build": {
"watch_dir": "./src",
"command": "esbuild --outdir=assets --format=esm --bundle --minify --sourcemap --sources-content src/*"
}
}