Files
nullara b760094a76 Major Issue Fix
Resolved issue preventing audio from playing in the background when sent from Silly Tavern. Resolved issue preventing API settings to not impact node such as volume and toggles.
2026-04-21 17:23:33 -07:00

483 lines
16 KiB
JavaScript

import { app } from '../../scripts/app.js'
console.log('[AutoAudioPlayer] FILE LOADED polling build')
let autoAudioUnlocked = false
const autoAudioNodeInstances = new Map()
let latestSeenRevision = 0
window.addEventListener('unhandledrejection', (event) => {
console.warn('[AutoAudioPlayer] global unhandled rejection:', event.reason)
})
function logAAP(...args) {
console.log('[AutoAudioPlayer]', ...args)
}
function formatTime(seconds) {
if (!Number.isFinite(seconds) || seconds < 0) return '0:00'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${String(secs).padStart(2, '0')}`
}
function styleRange(input) {
input.style.width = '100%'
input.style.margin = '0'
input.style.minWidth = '0'
input.style.accentColor = '#b866ff'
}
function findWidget(node, name) {
return node.widgets?.find((w) => w?.name === name) || null
}
function setWidgetValue(widget, value, node) {
if (!widget) return
widget.value = value
if (typeof widget.callback === 'function') widget.callback(value)
if (typeof node?.setDirtyCanvas === 'function') node.setDirtyCanvas(true, true)
}
function hideWidget(widget) {
if (!widget || widget.__autoAudioHidden) return
widget.__autoAudioHidden = true
widget.hidden = true
widget.type = 'converted-widget'
widget.computeSize = () => [0, 0]
widget.draw = () => {}
widget.mouse = () => false
widget.serializeValue = () => widget.value
if (widget.element) {
widget.element.style.display = 'none'
}
}
async function unlockAudioPlayback() {
if (autoAudioUnlocked) return true
try {
const probe = document.createElement('audio')
probe.muted = true
probe.playsInline = true
const result = probe.play()
if (result && typeof result.then === 'function') {
await result.catch(() => {})
}
probe.pause()
} catch (_) {
// ignore
}
autoAudioUnlocked = true
logAAP('audio unlock attempted')
return true
}
async function safePlay(audioEl, statusEl, reason = 'unknown') {
if (!audioEl?.src) {
logAAP('safePlay skipped, no src', reason)
return false
}
try {
logAAP('safePlay attempting', reason, audioEl.src)
const result = audioEl.play()
if (result && typeof result.then === 'function') {
await result
}
logAAP('safePlay success', reason)
return true
} catch (err) {
console.warn('[AutoAudioPlayer] play() failed:', reason, err)
if (statusEl && !statusEl.textContent.includes('Autoplay blocked')) {
statusEl.textContent += ' • Autoplay blocked by browser until you click in the ComfyUI page once.'
}
return false
}
}
function applyPayloadToNode(node, payload, source = 'polling') {
const ui = node?.__autoAudioPlayer
if (!ui) return
if (!payload?.url) {
ui.status.textContent = 'Execution finished, but no audio preview payload was returned.'
logAAP('applyPayloadToNode no payload url', source, payload)
return
}
const cacheBustedUrl = `${payload.url}${payload.url.includes('?') ? '&' : '?'}t=${Date.now()}`
logAAP('setting audio src', source, cacheBustedUrl)
const preferredVolume = Number.isFinite(Number(payload.default_volume))
? Number(payload.default_volume)
: Number.isFinite(Number(findWidget(node, 'default_volume')?.value))
? Number(findWidget(node, 'default_volume')?.value)
: Number(ui.volume.value ?? 1)
const preferredLoop = payload.loop ?? findWidget(node, 'loop')?.value ?? ui.loopToggle.checked
const preferredAutoplay = payload.autoplay ?? findWidget(node, 'autoplay')?.value ?? ui.autoplayToggle.checked
setWidgetValue(findWidget(node, 'default_volume'), preferredVolume, node)
setWidgetValue(findWidget(node, 'loop'), !!preferredLoop, node)
setWidgetValue(findWidget(node, 'autoplay'), preferredAutoplay !== false, node)
ui.audio.pause()
ui.audio.currentTime = 0
ui.audio.src = cacheBustedUrl
ui.audio.load()
ui.volume.value = String(preferredVolume)
ui.audio.volume = preferredVolume
ui.loopToggle.checked = !!preferredLoop
ui.audio.loop = ui.loopToggle.checked
ui.autoplayToggle.checked = preferredAutoplay !== false
const durationText = Number.isFinite(payload.duration) ? formatTime(payload.duration) : '0:00'
ui.status.textContent = `Audio ready • ${payload.sample_rate ?? '?'} Hz • ${payload.channels ?? '?'} ch • ${durationText}`
ui.syncButtonLabel()
ui.syncProgress()
ui.syncTimeLabel()
if (preferredAutoplay) {
Promise.resolve()
.then(async () => {
logAAP('attempting autoplay', source)
await safePlay(ui.audio, ui.status, `${source}-autoplay`)
ui.syncButtonLabel()
})
.catch((err) => {
console.warn('[AutoAudioPlayer] autoplay promise chain failed:', source, err)
ui.syncButtonLabel()
})
}
node.setDirtyCanvas?.(true, true)
}
async function pollLatestAudioState() {
try {
const response = await fetch('/auto_audio_player/latest', { cache: 'no-store' })
if (!response.ok) return
const data = await response.json()
const revision = Number(data?.revision ?? 0)
if (!revision || revision <= latestSeenRevision) return
latestSeenRevision = revision
logAAP('poll hit new revision', revision, data)
for (const node of autoAudioNodeInstances.values()) {
applyPayloadToNode(node, data, 'polling')
}
} catch (err) {
console.warn('[AutoAudioPlayer] polling failed:', err)
}
}
if (!window.__autoAudioPlayerUnlockBound) {
window.__autoAudioPlayerUnlockBound = true
const unlockOnce = async () => {
await unlockAudioPlayback()
window.removeEventListener('pointerdown', unlockOnce, true)
window.removeEventListener('keydown', unlockOnce, true)
}
window.addEventListener('pointerdown', unlockOnce, true)
window.addEventListener('keydown', unlockOnce, true)
}
if (!window.__autoAudioPlayerPollingBound) {
window.__autoAudioPlayerPollingBound = true
setInterval(pollLatestAudioState, 1000)
}
app.registerExtension({
name: 'custom.auto_audio_player',
async beforeRegisterNodeDef(nodeType, nodeData) {
console.log('[AutoAudioPlayer] beforeRegisterNodeDef seen', nodeData?.name)
const nodeName = nodeData?.name || nodeData?.display_name || nodeType?.comfyClass || nodeType?.type
if (nodeName !== 'AutoAudioPlayer') return
const onNodeCreated = nodeType.prototype.onNodeCreated
nodeType.prototype.onNodeCreated = function () {
const result = onNodeCreated?.apply(this, arguments)
if (this.__autoAudioPlayerInitialized) return result
this.__autoAudioPlayerInitialized = true
logAAP('nodeCreated', this.id)
const autoplayWidget = findWidget(this, 'autoplay')
const volumeWidget = findWidget(this, 'default_volume')
const loopWidget = findWidget(this, 'loop')
hideWidget(autoplayWidget)
hideWidget(volumeWidget)
hideWidget(loopWidget)
requestAnimationFrame(() => {
hideWidget(autoplayWidget)
hideWidget(volumeWidget)
hideWidget(loopWidget)
this.setDirtyCanvas?.(true, true)
})
const container = document.createElement('div')
container.style.display = 'flex'
container.style.flexDirection = 'column'
container.style.gap = '10px'
container.style.padding = '12px'
container.style.width = '100%'
container.style.boxSizing = 'border-box'
container.style.background = 'rgba(255,255,255,0.04)'
container.style.border = '1px solid rgba(255,255,255,0.08)'
container.style.borderRadius = '12px'
container.style.color = '#e8e8ea'
container.style.fontSize = '12px'
container.style.fontFamily = 'inherit'
container.style.overflow = 'hidden'
const status = document.createElement('div')
status.textContent = 'Waiting for audio...'
status.style.fontSize = '12px'
status.style.lineHeight = '1.35'
status.style.fontWeight = '600'
status.style.opacity = '0.95'
status.style.wordBreak = 'break-word'
const audio = document.createElement('audio')
audio.preload = 'auto'
audio.playsInline = true
audio.style.display = 'none'
const playRow = document.createElement('div')
playRow.style.display = 'grid'
playRow.style.gridTemplateColumns = '64px minmax(0,1fr) auto'
playRow.style.alignItems = 'center'
playRow.style.columnGap = '10px'
playRow.style.rowGap = '6px'
const playPauseButton = document.createElement('button')
playPauseButton.textContent = 'Play'
playPauseButton.style.height = '34px'
playPauseButton.style.minWidth = '64px'
playPauseButton.style.padding = '0 12px'
playPauseButton.style.border = '1px solid rgba(255,255,255,0.10)'
playPauseButton.style.borderRadius = '10px'
playPauseButton.style.background = 'rgba(255,255,255,0.10)'
playPauseButton.style.color = '#f2f2f3'
playPauseButton.style.cursor = 'pointer'
playPauseButton.style.fontWeight = '700'
playPauseButton.style.fontSize = '12px'
playPauseButton.style.lineHeight = '1'
const progress = document.createElement('input')
progress.type = 'range'
progress.min = '0'
progress.max = '1000'
progress.step = '1'
progress.value = '0'
styleRange(progress)
const timeLabel = document.createElement('div')
timeLabel.textContent = '0:00 / 0:00'
timeLabel.style.fontSize = '12px'
timeLabel.style.opacity = '0.92'
timeLabel.style.minWidth = '78px'
timeLabel.style.textAlign = 'right'
timeLabel.style.fontVariantNumeric = 'tabular-nums'
timeLabel.style.whiteSpace = 'nowrap'
timeLabel.style.fontWeight = '600'
playRow.append(playPauseButton, progress, timeLabel)
const controlsGrid = document.createElement('div')
controlsGrid.style.display = 'grid'
controlsGrid.style.gridTemplateColumns = '64px minmax(0,1fr) auto'
controlsGrid.style.alignItems = 'center'
controlsGrid.style.columnGap = '10px'
controlsGrid.style.rowGap = '10px'
const volumeLabel = document.createElement('div')
volumeLabel.textContent = 'Vol'
volumeLabel.style.fontWeight = '700'
volumeLabel.style.opacity = '0.95'
const volume = document.createElement('input')
volume.type = 'range'
volume.min = '0'
volume.max = '1'
volume.step = '0.01'
volume.value = String(volumeWidget?.value ?? 1)
styleRange(volume)
const loopWrap = document.createElement('label')
loopWrap.style.display = 'inline-flex'
loopWrap.style.alignItems = 'center'
loopWrap.style.justifySelf = 'end'
loopWrap.style.gap = '6px'
loopWrap.style.whiteSpace = 'nowrap'
loopWrap.style.cursor = 'pointer'
loopWrap.style.userSelect = 'none'
loopWrap.style.fontWeight = '700'
const loopToggle = document.createElement('input')
loopToggle.type = 'checkbox'
loopToggle.style.margin = '0'
loopToggle.style.accentColor = '#b866ff'
loopToggle.checked = !!loopWidget?.value
const loopText = document.createElement('span')
loopText.textContent = 'Loop'
loopWrap.append(loopToggle, loopText)
const autoplayLabel = document.createElement('div')
autoplayLabel.textContent = 'Autoplay'
autoplayLabel.style.fontWeight = '700'
autoplayLabel.style.opacity = '0.95'
const autoplayToggleWrap = document.createElement('label')
autoplayToggleWrap.style.display = 'inline-flex'
autoplayToggleWrap.style.alignItems = 'center'
autoplayToggleWrap.style.justifySelf = 'start'
autoplayToggleWrap.style.gap = '6px'
autoplayToggleWrap.style.cursor = 'pointer'
autoplayToggleWrap.style.userSelect = 'none'
autoplayToggleWrap.style.fontWeight = '700'
const autoplayToggle = document.createElement('input')
autoplayToggle.type = 'checkbox'
autoplayToggle.style.margin = '0'
autoplayToggle.style.accentColor = '#b866ff'
autoplayToggle.checked = autoplayWidget?.value !== false
autoplayToggleWrap.append(autoplayToggle)
const spacer = document.createElement('div')
spacer.style.minWidth = '1px'
controlsGrid.append(volumeLabel, volume, loopWrap, autoplayLabel, autoplayToggleWrap, spacer)
container.append(status, playRow, controlsGrid, audio)
this.addDOMWidget('auto_audio_player', 'auto_audio_player', container)
this.size = [Math.max(this.size?.[0] || 320, 360), Math.max(this.size?.[1] || 220, 200)]
this.setDirtyCanvas(true, true)
const syncButtonLabel = () => {
playPauseButton.textContent = audio.paused ? 'Play' : 'Pause'
}
const syncTimeLabel = () => {
const current = audio.currentTime || 0
const total = Number.isFinite(audio.duration) ? audio.duration : 0
timeLabel.textContent = `${formatTime(current)} / ${formatTime(total)}`
}
const syncProgress = () => {
const total = Number.isFinite(audio.duration) ? audio.duration : 0
if (total <= 0) {
progress.value = '0'
return
}
progress.value = String(Math.round((audio.currentTime / total) * 1000))
}
playPauseButton.onclick = async () => {
if (!audio.src) return
try {
if (audio.paused) {
await safePlay(audio, status, 'manual-button')
} else {
audio.pause()
logAAP('manual pause')
}
} catch (err) {
console.warn('[AutoAudioPlayer] play/pause error:', err)
}
syncButtonLabel()
}
progress.oninput = () => {
const total = Number.isFinite(audio.duration) ? audio.duration : 0
if (total <= 0) return
audio.currentTime = (Number(progress.value) / 1000) * total
syncTimeLabel()
}
volume.oninput = () => {
const value = Number(volume.value)
audio.volume = value
setWidgetValue(volumeWidget, value, this)
logAAP('volume changed', value)
}
loopToggle.onchange = () => {
audio.loop = loopToggle.checked
setWidgetValue(loopWidget, loopToggle.checked, this)
logAAP('loop changed', loopToggle.checked)
}
autoplayToggle.onchange = () => {
setWidgetValue(autoplayWidget, autoplayToggle.checked, this)
logAAP('autoplay changed', autoplayToggle.checked)
}
audio.addEventListener('play', () => {
logAAP('audio event: play')
syncButtonLabel()
})
audio.addEventListener('pause', () => {
logAAP('audio event: pause')
syncButtonLabel()
})
audio.addEventListener('timeupdate', () => {
syncProgress()
syncTimeLabel()
})
audio.addEventListener('loadedmetadata', () => {
logAAP('audio event: loadedmetadata', audio.duration)
syncProgress()
syncTimeLabel()
})
audio.addEventListener('ended', () => {
logAAP('audio event: ended')
syncButtonLabel()
syncProgress()
syncTimeLabel()
})
audio.addEventListener('error', () => {
logAAP('audio event: error', audio.error)
})
this.__autoAudioPlayer = {
status,
audio,
progress,
volume,
loopToggle,
autoplayToggle,
syncButtonLabel,
syncProgress,
syncTimeLabel,
}
autoAudioNodeInstances.set(String(this.id), this)
logAAP('registered node instance', this.id)
const onRemoved = this.onRemoved
this.onRemoved = function () {
autoAudioNodeInstances.delete(String(this.id))
logAAP('removed node instance', this.id)
return onRemoved?.apply(this, arguments)
}
return result
}
}
})