refactor for npm instead of submodule
/ release (push) Successful in 37s

This commit is contained in:
2025-12-20 07:39:13 -08:00
parent 7cc765ddc4
commit 2134290895
9 changed files with 544 additions and 475 deletions
+2 -1
View File
@@ -1,2 +1,3 @@
node_modules
.wrangler
.wrangler
assets/*.js*
+1
View File
@@ -0,0 +1 @@
@sugoidogo:registry=https://gitea.sugoidogo.com/api/packages/sugoidogo/npm/
+1 -373
View File
@@ -118,376 +118,4 @@
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">
try{
const sentry=await import('https://cdn.jsdelivr.net/npm/@sentry/browser@8/+esm')
sentry.init({
dsn:'https://d0b7617221824fafa3cd9a4d7039a612@app.glitchtip.com/8355',
environment:location.hostname,
release:"8.1.0",
integrations: [
sentry.feedbackIntegration({colorScheme:"light"})
]
})
}catch(e){
console.warn('automatic error reporting failed to load',e)
}
try{
if(window.location.search.includes('remotejs')){
const channel=new URLSearchParams(window.location.search).get('remotejs')
const script=document.createElement('script')
script.src='https://remotejs.com/agent/agent.js'
script.setAttribute("data-consolejs-channel",channel)
document.head.appendChild(script)
}
}catch(e){
console.warn('remotejs load failed',e)
}
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})
})
}).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://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('/')
}
fetch(url).then(response=>response.text())
.then(html=>{
const script=document.createElement('script')
script.innerHTML='const config='+JSON.stringify(config)
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)
</script>
<script type="module" src="index.js"></script>
-99
View File
@@ -22,102 +22,3 @@
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">
try{
const sentry=await import('https://cdn.jsdelivr.net/npm/@sentry/browser@8/+esm')
sentry.init({
dsn:'https://d0b7617221824fafa3cd9a4d7039a612@app.glitchtip.com/8355',
// this file is loaded locally, this information is not helpful
//environment:location.hostname,
release:"8.1.0"
})
}catch(e){
console.warn('automatic error reporting failed to load',e)
}
try{
if(window.location.search.includes('remotejs')){
const channel=new URLSearchParams(window.location.search).get('remotejs')
const script=document.createElement('script')
script.src='https://remotejs.com/agent/agent.js'
script.setAttribute("data-consolejs-channel",channel)
document.head.appendChild(script)
}
}catch(e){
console.warn('remotejs load failed',e)
}
console.debug(config)
if (!(config.assets instanceof Array)) {
config.assets = [config.assets]
}
if(typeof config.sources == 'string'){
config.sources=[config.sources]
}
import loadAsset from 'https://sugoidogo.github.io/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>
+119 -1
View File
@@ -4,6 +4,11 @@
"requires": true,
"packages": {
"": {
"dependencies": {
"@sugoidogo/js-util": "^0.2.0",
"localforage": "^1.10.0",
"obs-websocket-js": "^5.0.7"
},
"devDependencies": {
"wrangler": "^4.56.0"
}
@@ -996,6 +1001,15 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@msgpack/msgpack": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-2.8.0.tgz",
"integrity": "sha512-h9u4u/jiIRKbq25PM+zymTyW6bhTzELvOoUd+AvYriWOAKpLGnIamaET3pnHYoI5iYphAHBI4ayx0MehR+VVPQ==",
"license": "ISC",
"engines": {
"node": ">= 10"
}
},
"node_modules/@poppinss/colors": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz",
@@ -1045,6 +1059,11 @@
"dev": true,
"license": "CC0-1.0"
},
"node_modules/@sugoidogo/js-util": {
"version": "0.2.0",
"resolved": "https://gitea.sugoidogo.com/api/packages/sugoidogo/npm/%40sugoidogo%2Fjs-util/-/0.2.0/js-util-0.2.0.tgz",
"integrity": "sha512-2Jm+MVTzALFOeDNq1I9ZsW657IM67GA+rxeADcVX412LanqEAttmbGwEu3W7uVc1Q8Lk2xDQpmcxB9rgThp+CQ=="
},
"node_modules/acorn": {
"version": "8.14.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
@@ -1134,6 +1153,29 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -1196,6 +1238,12 @@
"@esbuild/win32-x64": "0.27.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/exit-hook": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-2.2.1.tgz",
@@ -1231,6 +1279,12 @@
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/is-arrayish": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
@@ -1238,6 +1292,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/isomorphic-ws": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-5.0.0.tgz",
"integrity": "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==",
"license": "MIT",
"peerDependencies": {
"ws": "*"
}
},
"node_modules/kleur": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@@ -1248,6 +1311,24 @@
"node": ">=6"
}
},
"node_modules/lie": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz",
"integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/localforage": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz",
"integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==",
"license": "Apache-2.0",
"dependencies": {
"lie": "3.1.1"
}
},
"node_modules/mime": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz",
@@ -1288,6 +1369,30 @@
"node": ">=18.0.0"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/obs-websocket-js": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/obs-websocket-js/-/obs-websocket-js-5.0.7.tgz",
"integrity": "sha512-SdSNSyrLVR6F0ogInKr7qcadV1tYaTUse/vbabxjkUL8hU3P3dyifxkZ7pEkDDrtCp3TkQ53Enx23kgZO0Cjcw==",
"license": "MIT",
"dependencies": {
"@msgpack/msgpack": "^2.7.1",
"crypto-js": "^4.1.1",
"debug": "^4.3.2",
"eventemitter3": "^5.0.1",
"isomorphic-ws": "^5.0.0",
"type-fest": "^3.11.0",
"ws": "^8.13.0"
},
"engines": {
"node": ">16.0"
}
},
"node_modules/path-to-regexp": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
@@ -1397,6 +1502,18 @@
"license": "0BSD",
"optional": true
},
"node_modules/type-fest": {
"version": "3.13.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz",
"integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==",
"license": "(MIT OR CC0-1.0)",
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/undici": {
"version": "7.14.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.14.0.tgz",
@@ -1425,6 +1542,7 @@
"dev": true,
"hasInstallScript": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"workerd": "bin/workerd"
},
@@ -1478,8 +1596,8 @@
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.0.0"
},
+8
View File
@@ -1,5 +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"
}
}
+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://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('/')
}
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) })
+5 -1
View File
@@ -4,5 +4,9 @@
"assets": {"directory": "assets"},
"workers_dev": false,
"keep_vars": true,
"compatibility_date": "2025-12-20"
"compatibility_date": "2025-12-20",
"build": {
"watch_dir": "./src",
"command": "esbuild --outdir=assets --format=esm --bundle --minify --sourcemap --sources-content src/*"
}
}