Add native music queue controls, artwork and settled V4V banner
This commit is contained in:
@@ -24,7 +24,7 @@ describe('app media session boundary', () => {
|
||||
bridge.connect()
|
||||
const hello = child.postMessage.mock.calls[0]![0]
|
||||
expect(child.postMessage.mock.calls[0]![1]).toBe('https://node.test:7475')
|
||||
const state = { type: 'archipelago:media-state', version: 1, session: hello.session, available: true, title: 'Song', artist: 'Artist', playing: true, position: 22, duration: 100 }
|
||||
const state = { type: 'archipelago:media-state', version: 1, session: hello.session, available: true, title: 'Song', artist: 'Artist', playing: true, position: 22, duration: 100, artwork: '/media/cover.jpg', shuffle: true }
|
||||
const message = (data = state, origin = 'https://node.test:7475', source: unknown = child) => bridge.handle({ data, origin, source } as MessageEvent)
|
||||
message(state, 'http://node.test:7475'); message(state, 'https://evil.test'); message(state, 'https://node.test:7475', {})
|
||||
message({ ...state, session: 'wrong' }); message({ ...state, duration: NaN })
|
||||
@@ -32,6 +32,9 @@ describe('app media session boundary', () => {
|
||||
message()
|
||||
expect(fixture.player.updateExternal).toHaveBeenCalledOnce()
|
||||
const [controller, snapshot, shown] = fixture.player.updateExternal.mock.calls[0]!
|
||||
expect(snapshot.artwork).toBe('https://node.test:7475/media/cover.jpg'); expect(snapshot.shuffle).toBe(true);
|
||||
controller.next(); controller.previous(); controller.shuffle();
|
||||
expect(child.postMessage.mock.calls.slice(-3).map(call => call[0].command)).toEqual(['next', 'previous', 'shuffle']);
|
||||
expect(snapshot.position).toBe(22); expect(shown).toBe(false)
|
||||
controller.seek(30)
|
||||
expect(child.postMessage.mock.calls[child.postMessage.mock.calls.length - 1]![0]).toMatchObject({ command: 'seek', position: 30, session: hello.session })
|
||||
|
||||
@@ -18,6 +18,7 @@ export function useAppMediaBridge(appId: Ref<string>, url: Ref<string>, frame: R
|
||||
frame.value?.contentWindow?.postMessage({ type: 'archipelago:media-connect', version: 1, session }, origin())
|
||||
}
|
||||
const controller = { id, play: () => command('play'), pause: () => command('pause'), seek: (position: number) => command('seek', position),
|
||||
next: () => command('next'), previous: () => command('previous'), shuffle: () => command('shuffle'),
|
||||
open: () => launcher.openSession(appId.value), close: () => {
|
||||
if (launcher.mediaAppId === appId.value && launcher.panelAppId !== appId.value) launcher.mediaAppId = null
|
||||
},
|
||||
@@ -31,8 +32,15 @@ export function useAppMediaBridge(appId: Ref<string>, url: Ref<string>, frame: R
|
||||
if (data.available !== true) { player.detachExternal(id); return }
|
||||
if (typeof data.title !== 'string' || typeof data.artist !== 'string' || typeof data.playing !== 'boolean'
|
||||
|| !Number.isFinite(data.position) || !Number.isFinite(data.duration) || data.position < 0 || data.duration < 0) return
|
||||
let artwork = ''
|
||||
try {
|
||||
if (typeof data.artwork === 'string' && data.artwork.length <= 2048 && data.artwork) {
|
||||
const candidate = new URL(data.artwork, url.value)
|
||||
if ((candidate.protocol === 'https:' || candidate.origin === origin()) && !candidate.username && !candidate.password) artwork = candidate.href
|
||||
}
|
||||
} catch { /* Invalid optional artwork never prevents playback controls. */ }
|
||||
const state: ExternalAudioState = { title: data.title.slice(0, 256), artist: data.artist.slice(0, 256), playing: data.playing,
|
||||
position: Math.min(data.position, data.duration), duration: data.duration }
|
||||
position: Math.min(data.position, data.duration), duration: data.duration, artwork, shuffle: data.shuffle === true }
|
||||
player.updateExternal(controller, state, visible.value)
|
||||
}
|
||||
watch(visible, shown => player.setExternalVisible(id, shown))
|
||||
|
||||
@@ -10,12 +10,15 @@ const duration = ref(0)
|
||||
const error = ref<string | null>(null)
|
||||
let initialized = false
|
||||
|
||||
export interface ExternalAudioState { title: string; artist?: string; playing: boolean; position: number; duration: number }
|
||||
export interface ExternalAudioState { title: string; artist?: string; playing: boolean; position: number; duration: number; artwork?: string; shuffle?: boolean }
|
||||
export interface ExternalAudioController {
|
||||
id: string
|
||||
play: () => void
|
||||
pause: () => void
|
||||
seek: (position: number) => void
|
||||
next?: () => void
|
||||
previous?: () => void
|
||||
shuffle?: () => void
|
||||
open: () => void
|
||||
close: () => void
|
||||
}
|
||||
@@ -23,6 +26,11 @@ let external: ExternalAudioController | null = null
|
||||
const suppressedExternal = new Set<string>()
|
||||
const externalVisible = ref(false)
|
||||
const externalApp = ref(false)
|
||||
const externalArtwork = ref('')
|
||||
const externalShuffle = ref(false)
|
||||
function nextExternal() { external?.next?.() }
|
||||
function previousExternal() { external?.previous?.() }
|
||||
function shuffleExternal() { external?.shuffle?.() }
|
||||
|
||||
function updateExternal(controller: ExternalAudioController, state: ExternalAudioState, visible: boolean) {
|
||||
if (suppressedExternal.has(controller.id)) { if (!state.playing) suppressedExternal.delete(controller.id); return }
|
||||
@@ -32,6 +40,8 @@ function updateExternal(controller: ExternalAudioController, state: ExternalAudi
|
||||
if (external) { suppressedExternal.add(external.id); external.pause() }
|
||||
external = controller
|
||||
}
|
||||
externalArtwork.value = state.artwork || ''
|
||||
externalShuffle.value = state.shuffle === true
|
||||
externalApp.value = true
|
||||
externalVisible.value = visible
|
||||
currentSrc.value = `app:${controller.id}`
|
||||
@@ -45,6 +55,8 @@ function updateExternal(controller: ExternalAudioController, state: ExternalAudi
|
||||
function detachExternal(id: string) {
|
||||
if (external?.id !== id) return
|
||||
external = null
|
||||
externalArtwork.value = ''
|
||||
externalShuffle.value = false
|
||||
externalApp.value = false
|
||||
externalVisible.value = false
|
||||
currentSrc.value = null
|
||||
@@ -169,6 +181,7 @@ const progress = computed(() => {
|
||||
|
||||
export function useAudioPlayer() {
|
||||
return {
|
||||
externalArtwork, externalShuffle, nextExternal, previousExternal, shuffleExternal,
|
||||
updateExternal, detachExternal, releaseExternal, setExternalVisible, openExternal, externalVisible, externalApp,
|
||||
play,
|
||||
pause,
|
||||
|
||||
Reference in New Issue
Block a user