feat(companion): retain audio with native media controls

This commit is contained in:
archipelago
2026-10-07 16:10:10 -04:00
parent b9c75b2141
commit 235f6d04d5
17 changed files with 830 additions and 27 deletions
Binary file not shown.
@@ -1,4 +1,4 @@
{
"versionName": "0.5.35",
"versionCode": 55
"versionName": "0.5.36",
"versionCode": 56
}
@@ -69,8 +69,10 @@
<script setup lang="ts">
import { ref, watch, nextTick, onBeforeUnmount } from 'vue'
import { useAudioPlayer } from '@/composables/useAudioPlayer'
import { useCompanionAudio } from '@/composables/useCompanionAudio'
const audioPlayer = useAudioPlayer()
useCompanionAudio(audioPlayer)
const barEl = ref<HTMLElement | null>(null)
// Publish the player's height as a CSS variable so page scroll containers can
@@ -0,0 +1,93 @@
import { mount } from '@vue/test-utils'
import { defineComponent, nextTick, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useCompanionAudio } from '../useCompanionAudio'
import type { useAudioPlayer } from '../useAudioPlayer'
beforeEach(() => vi.useFakeTimers())
afterEach(() => { vi.useRealTimers(); delete (window as any).ArchipelagoAudio })
function fixture(install = true) {
const messages: any[] = []
const bridge: any = { onmessage: null, postMessage: (raw: string) => messages.push(JSON.parse(raw)) }
if (install) (window as any).ArchipelagoAudio = bridge
const player = {
currentSrc: ref<string | null>('https://node.test/private?token=secret'), currentName: ref('A song'),
playing: ref(true), currentTime: ref(12), duration: ref(120), externalArtwork: ref(''),
externalApp: ref(true), externalShuffle: ref(false), error: ref<string | null>(null),
pause: vi.fn(), play: vi.fn(), seek: vi.fn(), nextExternal: vi.fn(), previousExternal: vi.fn(), shuffleExternal: vi.fn(),
stop: vi.fn(() => { player.currentSrc.value = null; player.playing.value = false }),
}
let audio!: ReturnType<typeof useCompanionAudio>
const wrapper = mount(defineComponent({ setup() { audio = useCompanionAudio(player as unknown as ReturnType<typeof useAudioPlayer>); return () => null } }))
return { messages, bridge, player, wrapper, audio,
command: (command: string, extra = {}) => bridge.onmessage({ data: JSON.stringify({ version: 1, type: 'command', session: messages[0]?.session, command, ...extra }) }) }
}
describe('companion native audio control of the existing player', () => {
it('sends bounded metadata and queue controls without stream URLs or credentials', async () => {
const f = fixture(); await nextTick()
expect(f.messages[0]).toMatchObject({ action: 'state', title: 'A song', playing: true, position: 12, duration: 120, next: true, previous: true, shuffle: true })
expect(JSON.stringify(f.messages)).not.toContain('secret')
expect(JSON.stringify(f.messages)).not.toContain('https:')
f.player.currentTime.value = 13; await nextTick(); f.player.currentTime.value = 14; await nextTick()
expect(f.messages).toHaveLength(1)
await vi.advanceTimersByTimeAsync(1000)
expect(f.messages).toHaveLength(2); expect(f.messages[1].position).toBe(14)
f.wrapper.unmount(); expect(f.messages[f.messages.length - 1].action).toBe('release')
})
it('starts unique valid sessions on LAN HTTP without crypto.randomUUID', async () => {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'randomUUID')
Object.defineProperty(crypto, 'randomUUID', { configurable: true, value: undefined })
const f = fixture()
try {
await nextTick()
const first = f.messages[0].session
expect(first).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
f.player.currentSrc.value = 'app:second'; await nextTick()
expect(f.messages[2].session).not.toBe(first)
} finally {
f.wrapper.unmount()
if (descriptor) Object.defineProperty(crypto, 'randomUUID', descriptor)
else Reflect.deleteProperty(crypto, 'randomUUID')
}
})
it('operates the same queue and ignores controls from a stale session', async () => {
const f = fixture(); await nextTick()
f.command('pause', { session: 'foreign' }); expect(f.player.pause).not.toHaveBeenCalled()
f.command('pause'); f.command('next'); f.command('previous'); f.command('shuffle'); f.command('seek', { position: 900 })
expect(f.player.pause).toHaveBeenCalledOnce(); expect(f.player.nextExternal).toHaveBeenCalledOnce()
expect(f.player.previousExternal).toHaveBeenCalledOnce(); expect(f.player.shuffleExternal).toHaveBeenCalledOnce()
expect(f.player.seek).toHaveBeenCalledWith(120)
f.command('seek', { position: '5' }); expect(f.player.seek).toHaveBeenCalledTimes(1)
f.command('stop'); await nextTick(); expect(f.player.stop).toHaveBeenCalledOnce()
expect(f.messages[f.messages.length - 1].action).toBe('release'); f.wrapper.unmount()
})
it('replaces native session when changing source and releases on logout/player stop', async () => {
const f = fixture(); await nextTick(); const first = f.messages[0].session
f.player.currentSrc.value = 'app:new'; await nextTick()
expect(f.messages[1]).toMatchObject({ action: 'release', session: first })
expect(f.messages[2].session).not.toBe(first)
f.command('pause', { session: first }); expect(f.player.pause).not.toHaveBeenCalled()
f.player.currentSrc.value = null; await nextTick()
expect(f.messages[f.messages.length - 1].action).toBe('release'); f.wrapper.unmount()
})
it('connects a late native bridge and answers synchronization while backgrounded', async () => {
const f = fixture(false); await nextTick(); expect(f.messages).toHaveLength(0)
;(window as any).ArchipelagoAudio = f.bridge
document.dispatchEvent(new Event('visibilitychange'))
expect(f.messages).toHaveLength(1)
f.command('sync'); expect(f.messages).toHaveLength(2)
f.wrapper.unmount(); const count = f.messages.length
await vi.advanceTimersByTimeAsync(30000); expect(f.messages).toHaveLength(count)
})
it('does not revive paused sessions and reports native start failures without leaking details', async () => {
const f = fixture(); await nextTick()
f.bridge.onmessage({ data: JSON.stringify({ version: 1, session: f.messages[0].session, type: 'error', error: 'sensitive details' }) })
expect(f.player.pause).toHaveBeenCalledOnce(); expect(f.player.error.value).toContain('Background playback')
expect(f.player.error.value).not.toContain('sensitive')
f.player.playing.value = false; await nextTick(); const count = f.messages.length
await vi.advanceTimersByTimeAsync(10000); expect(f.messages).toHaveLength(count)
f.wrapper.unmount()
})
})
@@ -0,0 +1,136 @@
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, signal: AbortSignal): Promise<string> {
if (!url) return ''
const source = new URL(url, window.location.href)
if (source.username || source.password || (source.protocol !== 'https:' && source.origin !== window.location.origin)) return ''
const response = await fetch(source.href, { signal, credentials: 'same-origin', referrerPolicy: 'no-referrer' })
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, 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, () => { 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 }
}
+4 -1
View File
@@ -1,7 +1,8 @@
// Authentication store — login, logout, session management
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { ref, watch } from 'vue'
import { useAudioPlayer } from '@/composables/useAudioPlayer'
import { rpcClient } from '../api/rpc-client'
import { useSyncStore } from './sync'
import { useResourcesStore } from './resources'
@@ -9,6 +10,7 @@ import { useResourcesStore } from './resources'
export const useAuthStore = defineStore('auth', () => {
// State
const isAuthenticated = ref(localStorage.getItem('neode-auth') === 'true')
watch(isAuthenticated, authenticated => { if (!authenticated) useAudioPlayer().stop() }, { flush: 'sync' })
const isLoading = ref(false)
const error = ref<string | null>(null)
let sessionValidated = false
@@ -75,6 +77,7 @@ export const useAuthStore = defineStore('auth', () => {
}
async function logout(): Promise<void> {
useAudioPlayer().stop()
const sync = useSyncStore()
try {
await rpcClient.logout()