140 lines
7.2 KiB
TypeScript
140 lines
7.2 KiB
TypeScript
import { onBeforeUnmount, watch } from 'vue'
|
|
import { useAudioPlayer } from './useAudioPlayer'
|
|
|
|
type AudioBridge = { postMessage: (message: string) => void; onmessage: ((event: { data: string }) => void) | null }
|
|
type NativeWindow = Window & { ArchipelagoAudio?: AudioBridge }
|
|
|
|
/** LAN HTTP WebViews expose getRandomValues, but not secure-context randomUUID. */
|
|
function audioSessionId(): string {
|
|
const bytes = crypto.getRandomValues(new Uint8Array(16))
|
|
bytes[6] = (bytes[6]! & 0x0f) | 0x40
|
|
bytes[8] = (bytes[8]! & 0x3f) | 0x80
|
|
const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
|
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
|
|
}
|
|
|
|
/** Send a small decoded thumbnail, never a protected URL or authorization data. */
|
|
async function thumbnail(url: string, admittedOrigin: string, signal: AbortSignal): Promise<string> {
|
|
if (!url) return ''
|
|
const source = new URL(url, window.location.href)
|
|
// An app's authenticated media origin can use a different port on LAN HTTP.
|
|
// Never authorize an arbitrary HTTP origin from the artwork payload itself.
|
|
if (source.username || source.password || !(source.protocol === 'https:'
|
|
|| (source.protocol === 'http:' && (source.origin === window.location.origin || source.origin === admittedOrigin)))) return ''
|
|
const response = await fetch(source.href, { signal, credentials: 'same-origin', referrerPolicy: 'no-referrer', redirect: 'error' })
|
|
if (!response.ok || !response.headers.get('content-type')?.startsWith('image/')) return ''
|
|
const reader = response.body?.getReader()
|
|
if (!reader) return ''
|
|
const chunks: Uint8Array[] = []
|
|
let size = 0
|
|
try {
|
|
while (true) {
|
|
const chunk = await reader.read()
|
|
if (chunk.done) break
|
|
size += chunk.value.byteLength
|
|
if (size > 512 * 1024) { await reader.cancel(); return '' }
|
|
chunks.push(chunk.value)
|
|
}
|
|
} finally { reader.releaseLock() }
|
|
const bytes = new Uint8Array(size)
|
|
let offset = 0
|
|
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length }
|
|
const picture = await createImageBitmap(new Blob([bytes]), { resizeWidth: 192, resizeHeight: 192, resizeQuality: 'medium' })
|
|
try {
|
|
const canvas = document.createElement('canvas'); canvas.width = 192; canvas.height = 192
|
|
const context = canvas.getContext('2d'); if (!context) return ''
|
|
context.drawImage(picture, 0, 0, 192, 192)
|
|
const result = canvas.toDataURL('image/jpeg', 0.75)
|
|
return result.length <= 90000 ? result : ''
|
|
} finally { picture.close() }
|
|
}
|
|
|
|
export function useCompanionAudio(player = useAudioPlayer()) {
|
|
let bridge: AudioBridge | undefined
|
|
let session = '', source = '', sequence = 0
|
|
let artwork = '', sentArtwork = ''
|
|
let imageRequest: AbortController | null = null
|
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
let disposed = false
|
|
let previousHandler: AudioBridge['onmessage'] = null
|
|
function post(value: object) { try { bridge?.postMessage(JSON.stringify(value)) } catch { /* Lifecycle cleanup can close the document channel. */ } }
|
|
function release() {
|
|
if (session) post({ version: 1, action: 'release', session })
|
|
session = ''; source = ''; artwork = ''; sentArtwork = ''
|
|
imageRequest?.abort(); imageRequest = null
|
|
if (timer) clearTimeout(timer)
|
|
timer = undefined
|
|
}
|
|
function receive(event: { data: string }) {
|
|
let value: any
|
|
try { value = JSON.parse(event.data) } catch { return }
|
|
if (value.version !== 1 || !session || value.session !== session) return
|
|
if (value.type === 'error') {
|
|
player.pause(); player.error.value = 'Background playback could not start. Reopen the companion and press Play.'
|
|
release(); return
|
|
}
|
|
if (value.type !== 'command') return
|
|
switch (value.command) {
|
|
case 'play': if (player.currentSrc.value && !player.playing.value) player.play(player.currentSrc.value, player.currentName.value); break
|
|
case 'pause': player.pause(); break
|
|
case 'stop': player.stop(); release(); break
|
|
case 'seek': if (Number.isFinite(value.position)) player.seek(Math.max(0, Math.min(value.position, player.duration.value))); break
|
|
case 'next': player.nextExternal(); break
|
|
case 'previous': player.previousExternal(); break
|
|
case 'shuffle': player.shuffleExternal(); break
|
|
case 'sync': publish(); break
|
|
}
|
|
}
|
|
function connect() {
|
|
const candidate = (window as NativeWindow).ArchipelagoAudio
|
|
if (!candidate) return false
|
|
if (bridge !== candidate) {
|
|
release()
|
|
if (bridge?.onmessage === receive) bridge.onmessage = previousHandler
|
|
bridge = candidate; previousHandler = candidate.onmessage; candidate.onmessage = receive
|
|
}
|
|
return true
|
|
}
|
|
function publish() {
|
|
if (disposed || !connect()) return
|
|
if (!player.currentSrc.value || !player.currentName.value) { release(); return }
|
|
if (!session || source !== player.currentSrc.value) {
|
|
if (!player.playing.value) { release(); return }
|
|
release(); session = audioSessionId(); source = player.currentSrc.value; sequence = 0
|
|
loadArtwork()
|
|
}
|
|
const duration = Number.isFinite(player.duration.value) ? Math.max(0, Math.min(player.duration.value, 604800)) : 0
|
|
const position = Number.isFinite(player.currentTime.value) ? Math.max(0, Math.min(player.currentTime.value, duration)) : 0
|
|
const message: Record<string, unknown> = { version: 1, action: 'state', session, sequence: ++sequence,
|
|
title: player.currentName.value.slice(0, 512), playing: player.playing.value, position, duration,
|
|
previous: player.externalApp.value, next: player.externalApp.value,
|
|
shuffle: player.externalApp.value, shuffled: player.externalShuffle.value }
|
|
if (artwork !== sentArtwork || sequence === 1) { message.artwork = artwork; sentArtwork = artwork }
|
|
post(message)
|
|
}
|
|
function loadArtwork() {
|
|
imageRequest?.abort(); artwork = ''
|
|
if (!session || !player.externalArtwork.value) return
|
|
const target = session; const request = new AbortController(); imageRequest = request
|
|
void thumbnail(player.externalArtwork.value, player.externalArtworkOrigin.value, request.signal).then(value => {
|
|
if (!disposed && !request.signal.aborted && target === session) { artwork = value; publish() }
|
|
}).catch(() => { /* Optional image failure never interrupts music. */ })
|
|
}
|
|
// Playback transitions/track changes are immediate; position updates are bounded.
|
|
watch([player.currentSrc, player.currentName, player.playing, player.externalShuffle], publish, { immediate: true, flush: 'post' })
|
|
watch([player.currentTime, player.duration], () => {
|
|
if (!timer) timer = setTimeout(() => { timer = undefined; publish() }, 1000)
|
|
})
|
|
watch([player.externalArtwork, player.externalArtworkOrigin], () => { loadArtwork(); publish() })
|
|
const sync = () => publish()
|
|
window.addEventListener('pageshow', sync)
|
|
document.addEventListener('visibilitychange', sync)
|
|
const heartbeat = setInterval(publish, 10000)
|
|
onBeforeUnmount(() => {
|
|
disposed = true; release(); clearInterval(heartbeat)
|
|
window.removeEventListener('pageshow', sync); document.removeEventListener('visibilitychange', sync)
|
|
if (bridge?.onmessage === receive) bridge.onmessage = previousHandler
|
|
})
|
|
return { sync }
|
|
}
|