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.
483 lines
16 KiB
JavaScript
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
|
|
}
|
|
}
|
|
}) |