feat(companion): retain audio with native media controls
This commit is contained in:
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 }
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user