Draft generic audio manifest admission and public media adapter

This commit is contained in:
archipelago
2026-10-07 01:05:33 -04:00
parent 30b5975534
commit f1fb388ded
9 changed files with 251 additions and 14 deletions
+58
View File
@@ -0,0 +1,58 @@
// Independently authored reference adapter for the public archipelago-v1 protocol.
// No application authentication, signing keys or stream URLs cross this bridge.
export function attachAudioBridge({ parentOrigin, snapshot, actions, unlocked = () => true, host = window }) {
const parsed = new URL(parentOrigin)
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.origin !== parentOrigin || parsed.username || parsed.password) throw new Error('Configure an exact trusted dashboard origin')
if (host.parent === host) return { publish() {}, dispose() {} }
let session = null, closed = false, queue = Promise.resolve()
const send = data => host.parent.postMessage({ version: 1, ...data }, parentOrigin)
function publish() {
if (closed || !session) return
const state = unlocked() ? snapshot() : null
if (!state || typeof state.title !== 'string' || typeof state.artist !== 'string'
|| typeof state.playing !== 'boolean' || typeof state.shuffle !== 'boolean'
|| !Number.isFinite(state.position) || !Number.isFinite(state.duration) || state.position < 0 || state.duration < 0) {
send({type:'archipelago:media-state',session,available:false}); return
}
send({type:'archipelago:media-state',session,available:true,title:state.title.slice(0,256),artist:state.artist.slice(0,256),
playing:state.playing,shuffle:state.shuffle,position:Math.min(state.position,state.duration),duration:state.duration,
artwork:typeof state.artwork === 'string' ? state.artwork.slice(0,2048) : ''})
}
function message(event) {
if (closed || event.source !== host.parent || event.origin !== parentOrigin || event.data?.version !== 1) return
const data = event.data
if (data.type === 'archipelago:media-connect') {
if (typeof data.session !== 'string' || !/^[0-9a-f]{32}$/.test(data.session)) return
session = data.session; publish(); return
}
if (data.type !== 'archipelago:media-control' || !session || data.session !== session || !unlocked()) return
if (!['play','pause','seek','next','previous','shuffle','snapshot'].includes(data.command)) return
if (data.command === 'seek' && (!Number.isFinite(data.position) || data.position < 0)) return
const expected = session
queue = queue.catch(() => {}).then(async () => {
if (closed || session !== expected || !unlocked()) return
if (data.command !== 'snapshot') {
const action = actions[data.command]
if (typeof action !== 'function') return
const state = snapshot()
if (data.command === 'seek') {
if (!Number.isFinite(state?.duration) || state.duration < 0) return
await action(Math.min(data.position, state.duration))
} else await action()
}
if (!closed && session === expected) publish()
}).catch(() => { if (!closed && session === expected) publish() })
}
const ready = () => { if (!closed) send({type:'archipelago:media-ready'}) }
function dispose() {
if (closed) return
if (session) send({type:'archipelago:media-state',session,available:false})
closed = true; session = null
host.removeEventListener('message',message); host.removeEventListener('pageshow',ready); host.removeEventListener('pagehide',leave)
actions.pause?.()
}
function leave(event) { if (!event.persisted) dispose() }
host.addEventListener('message',message); host.addEventListener('pageshow',ready); host.addEventListener('pagehide',leave)
ready()
return { publish, dispose }
}
@@ -0,0 +1,38 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { attachAudioBridge } from './archipelago-audio.mjs'
function fixture() {
const sent=[], handlers=new Map(), calls=[]
const parent={postMessage:(data,origin)=>sent.push({data,origin})}
const host={parent,addEventListener:(name,fn)=>handlers.set(name,fn),removeEventListener:name=>handlers.delete(name)}
let unlocked=true
const state={title:'Own track',artist:'Artist',playing:true,shuffle:false,position:4,duration:30}
const actions=Object.fromEntries(['play','pause','next','previous','shuffle','seek'].map(name=>[name,async value=>calls.push([name,value])]))
const bridge=attachAudioBridge({parentOrigin:'https://node.test',host,snapshot:()=>state,unlocked:()=>unlocked,actions})
const receive=(data,origin='https://node.test',source=parent)=>handlers.get('message')?.({source,origin,data:{version:1,...data}})
return {sent,handlers,calls,state,bridge,receive,lock:()=>{unlocked=false}}
}
test('validates parent and nonce; delegates real controls with bounded seeks',async()=>{
const f=fixture(),session='a'.repeat(32)
f.receive({type:'archipelago:media-connect',session},'https://foreign.test')
assert.equal(f.sent.length,1)
f.receive({type:'archipelago:media-connect',session})
f.receive({type:'archipelago:media-control',session:'b'.repeat(32),command:'play'})
f.receive({type:'archipelago:media-control',session,command:'seek',position:99})
f.receive({type:'archipelago:media-control',session,command:'previous'})
await new Promise(setImmediate)
assert.deepEqual(f.calls,[['seek',30],['previous',undefined]])
assert(f.sent.every(message=>message.origin==='https://node.test'))
f.lock();f.bridge.publish();assert.equal(f.sent.at(-1).data.available,false)
assert.equal('title' in f.sent.at(-1).data,false)
f.bridge.dispose();assert.equal(f.handlers.size,0)
})
test('stale queued commands do not control a replacement handshake',async()=>{
const f=fixture(),session='a'.repeat(32)
f.receive({type:'archipelago:media-connect',session})
f.receive({type:'archipelago:media-control',session,command:'next'})
f.receive({type:'archipelago:media-connect',session:'b'.repeat(32)})
await new Promise(setImmediate)
assert.deepEqual(f.calls,[])
f.bridge.dispose()
})
+28
View File
@@ -0,0 +1,28 @@
<!doctype html>
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<!-- Deployment-owned configuration: replace with the exact approved dashboard origin.
Never fill this value from a URL parameter or an unverified incoming message. -->
<meta name="dashboard-origin" content="https://node.example">
<title>Public audio bridge example</title>
<style>body{max-width:42rem;margin:2rem auto;padding:1rem;font:18px system-ui}button,input,audio{margin:.5rem 0;max-width:100%}button{min-height:44px;padding:0 1rem}</style>
<h1>Audio bridge example</h1><p>Select your own audio files. This example uploads nothing and contains no demo catalog, private app source or bundled recordings.</p>
<input id="files" type="file" accept="audio/*" multiple aria-label="Choose audio files">
<p id="track" aria-live="polite">No track selected</p><audio id="audio" controls></audio>
<div><button id="previous">Previous</button> <button id="next">Next</button> <button id="shuffle" aria-pressed="false">Shuffle</button></div>
<script type="module">
import { attachAudioBridge } from './archipelago-audio.mjs'
const audio=document.querySelector('#audio'), label=document.querySelector('#track'), shuffleButton=document.querySelector('#shuffle')
let files=[], urls=[], index=0, shuffled=false
function select(next){if(!files.length)return;index=(next+files.length)%files.length;audio.src=urls[index];label.textContent=files[index].name;bridge.publish()}
async function advance(){select(shuffled?Math.floor(Math.random()*files.length):index+1);if(files.length)await audio.play()}
const actions={play:()=>audio.play(),pause:()=>audio.pause(),seek:seconds=>{audio.currentTime=seconds},next:advance,
previous:async()=>{if(audio.currentTime>3)audio.currentTime=0;else select(index-1);if(files.length)await audio.play()},
shuffle:()=>{shuffled=!shuffled;shuffleButton.setAttribute('aria-pressed',String(shuffled))}}
const bridge=attachAudioBridge({parentOrigin:document.querySelector('meta[name="dashboard-origin"]').content,actions,
snapshot:()=>files.length?{title:files[index].name,artist:'Local file',playing:!audio.paused,shuffle:shuffled,position:audio.currentTime,duration:audio.duration}:null})
document.querySelector('#files').onchange=event=>{audio.pause();urls.forEach(URL.revokeObjectURL);files=Array.from(event.target.files);urls=files.map(file=>URL.createObjectURL(file));select(0)}
for(const name of ['previous','next','shuffle'])document.querySelector('#'+name).onclick=async()=>{try{await actions[name]()}finally{bridge.publish()}}
for(const name of ['play','pause','timeupdate','durationchange','loadedmetadata','error'])audio.addEventListener(name,bridge.publish)
audio.addEventListener('ended',()=>advance().catch(()=>bridge.publish()))
window.addEventListener('pagehide',event=>{if(!event.persisted){bridge.dispose();urls.forEach(URL.revokeObjectURL)}})
</script></html>