Allow notification thumbnails from admitted LAN app origins
This commit is contained in:
@@ -1,6 +1,13 @@
|
|||||||
# Companion background audio investigation — 2026-10-07
|
# Companion background audio investigation — 2026-10-07
|
||||||
|
|
||||||
Status: **OPEN — physical acceptance failed.**
|
Status: **OPEN — operator now reports APK57 background playback working; notification artwork missing.**
|
||||||
|
|
||||||
|
Latest operator feedback after installing 0.5.37: "works now, doesn't show the song
|
||||||
|
image in the notification widget though only." Record reported background audio
|
||||||
|
and notification presence as working for that test. Do not infer full queue/control,
|
||||||
|
restart, process-death or video acceptance, or a confirmed cause for the earlier
|
||||||
|
failure: build57 added diagnostics rather than a known playback correction.
|
||||||
|
Artwork investigation is now the priority. Historical APK56 failure follows.
|
||||||
|
|
||||||
APK 0.5.36 (build 56) plays V4V while foregrounded, but the operator reports audio
|
APK 0.5.36 (build 56) plays V4V while foregrounded, but the operator reports audio
|
||||||
stopping about five seconds after locking the phone or switching applications,
|
stopping about five seconds after locking the phone or switching applications,
|
||||||
@@ -70,8 +77,9 @@ diagnostics and copy the report. No need to use a different track or USB.
|
|||||||
packaging. No keystore was staged, copied into evidence, or replaced.
|
packaging. No keystore was staged, copied into evidence, or replaced.
|
||||||
- Durable evidence: `~/.local/state/archipelago/release-qualification/companion-057/`.
|
- Durable evidence: `~/.local/state/archipelago/release-qualification/companion-057/`.
|
||||||
No wallet, payment, catalog, OTA, ISO, or native node-service deployment changed.
|
No wallet, payment, catalog, OTA, ISO, or native node-service deployment changed.
|
||||||
- Physical background playback remains **unaccepted**; the diagnostic report is
|
- At diagnostic delivery physical background playback remained unaccepted. The
|
||||||
the next evidence required from the affected phone.
|
later operator report above establishes it worked in their APK57 test; artwork
|
||||||
|
remains unresolved on the phone.
|
||||||
|
|
||||||
## Companion shell routing check
|
## Companion shell routing check
|
||||||
|
|
||||||
@@ -88,3 +96,38 @@ An initial Apps-button probe found no V4V card/package on `/dashboard/apps`; it
|
|||||||
therefore did not exercise an actual Apps launch button. Keep that failed probe
|
therefore did not exercise an actual Apps launch button. Keep that failed probe
|
||||||
distinct from the subsequent successful Discover/launcher check. No routing fix
|
distinct from the subsequent successful Discover/launcher check. No routing fix
|
||||||
is justified by these results. Scripts/logs are in the durable diagnostic evidence.
|
is justified by these results. Scripts/logs are in the durable diagnostic evidence.
|
||||||
|
|
||||||
|
## Artwork origin correction
|
||||||
|
|
||||||
|
Confirmed source defect: `useAppMediaBridge` admits cover URLs on the verified
|
||||||
|
app's origin (for example the node's HTTP :7475 port), but `useCompanionAudio`
|
||||||
|
previously rejected every HTTP cover outside the dashboard's own origin. Thus a
|
||||||
|
cover could render in the dashboard and never become a native thumbnail.
|
||||||
|
|
||||||
|
The media controller now carries its admitted origin explicitly into the player;
|
||||||
|
an app's `media-state` payload cannot supply/override this authorization. Thumbnail
|
||||||
|
fetching accepts HTTP only from the dashboard or that admitted origin. HTTPS
|
||||||
|
artwork remains supported. Cross-origin requests do not send cookies, no referrer
|
||||||
|
is sent, and redirects fail closed. No stream URL, cover URL, credentials, or
|
||||||
|
second decoder enters the native bridge. Optional image failure still leaves
|
||||||
|
playback running. CORS remains enforced; this does not bypass an image server's
|
||||||
|
access policy or add a server-side image proxy.
|
||||||
|
|
||||||
|
Evidence:
|
||||||
|
|
||||||
|
- Focused four-file media suite: 26 tests passed. A test-only `Array.at` call was
|
||||||
|
corrected for the configured ES library after actual app-project typechecking;
|
||||||
|
the affected file was rerun separately.
|
||||||
|
- `vue-tsc --noEmit -p tsconfig.app.json` passed. The reference-only root tsconfig
|
||||||
|
has no files; its bare `--noEmit` result is not used as application evidence.
|
||||||
|
- Actual Chromium A/B fixture with an HTTP app on a separate port: old composable
|
||||||
|
never produced a thumbnail; corrected composable produced a real JPEG. Browser
|
||||||
|
checks also verified no cross-origin cookie, no followed redirect, and playback
|
||||||
|
remained active after the optional image error.
|
||||||
|
- No native runtime or APK version change. A positive native JPEG/notification/
|
||||||
|
metadata regression is prepared separately and awaits its coordinated test slot.
|
||||||
|
- This identifies and fixes a real artwork boundary defect, not the operator's
|
||||||
|
exact cover URL. Physical notification-artwork acceptance remains open until
|
||||||
|
the qualified UI reaches the node and the operator verifies it.
|
||||||
|
|
||||||
|
Evidence folder: `~/.local/state/archipelago/release-qualification/companion-artwork-20261007/`.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import GlobalAudioPlayer from '../GlobalAudioPlayer.vue'
|
|||||||
describe('native app player controls', () => {
|
describe('native app player controls', () => {
|
||||||
it('renders app artwork and synchronized controls without a second audio source', async () => {
|
it('renders app artwork and synchronized controls without a second audio source', async () => {
|
||||||
fixture.player = {
|
fixture.player = {
|
||||||
currentName: ref('Song · Artist'), externalVisible: ref(false), externalApp: ref(true), externalArtwork: ref('https://node.test:7475/media/cover.jpg'), externalShuffle: ref(true),
|
currentName: ref('Song · Artist'), externalVisible: ref(false), externalApp: ref(true), externalArtwork: ref('https://node.test:7475/media/cover.jpg'), externalArtworkOrigin: ref('https://node.test:7475'), externalShuffle: ref(true),
|
||||||
playing: ref(true), loading: ref(false), error: ref(null), currentTime: ref(4), duration: ref(90), progress: ref(5), currentSrc: ref('app:session'),
|
playing: ref(true), loading: ref(false), error: ref(null), currentTime: ref(4), duration: ref(90), progress: ref(5), currentSrc: ref('app:session'),
|
||||||
previousExternal: vi.fn(), nextExternal: vi.fn(), shuffleExternal: vi.fn(), openExternal: vi.fn(), pause: vi.fn(), play: vi.fn(), stop: vi.fn(), seek: vi.fn(),
|
previousExternal: vi.fn(), nextExternal: vi.fn(), shuffleExternal: vi.fn(), openExternal: vi.fn(), pause: vi.fn(), play: vi.fn(), stop: vi.fn(), seek: vi.fn(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ describe('app media session boundary', () => {
|
|||||||
bridge.connect()
|
bridge.connect()
|
||||||
const hello = child.postMessage.mock.calls[0]![0]
|
const hello = child.postMessage.mock.calls[0]![0]
|
||||||
expect(child.postMessage.mock.calls[0]![1]).toBe('https://node.test:7475')
|
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, artwork: '/media/cover.jpg', shuffle: true }
|
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', artworkOrigin: 'http://untrusted.test', shuffle: true }
|
||||||
const message = (data = state, origin = 'https://node.test:7475', source: unknown = child) => bridge.handle({ data, origin, source } as MessageEvent)
|
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, 'http://node.test:7475'); message(state, 'https://evil.test'); message(state, 'https://node.test:7475', {})
|
||||||
message({ ...state, session: 'wrong' }); message({ ...state, duration: NaN })
|
message({ ...state, session: 'wrong' }); message({ ...state, duration: NaN })
|
||||||
@@ -57,6 +57,7 @@ describe('app media session boundary', () => {
|
|||||||
message()
|
message()
|
||||||
expect(fixture.player.updateExternal).toHaveBeenCalledOnce()
|
expect(fixture.player.updateExternal).toHaveBeenCalledOnce()
|
||||||
const [controller, snapshot, shown] = fixture.player.updateExternal.mock.calls[0]!
|
const [controller, snapshot, shown] = fixture.player.updateExternal.mock.calls[0]!
|
||||||
|
expect(controller.artworkOrigin).toBe('https://node.test:7475');
|
||||||
expect(snapshot.artwork).toBe('https://node.test:7475/media/cover.jpg'); expect(snapshot.shuffle).toBe(true);
|
expect(snapshot.artwork).toBe('https://node.test:7475/media/cover.jpg'); expect(snapshot.shuffle).toBe(true);
|
||||||
controller.next(); controller.previous(); controller.shuffle();
|
controller.next(); controller.previous(); controller.shuffle();
|
||||||
expect(child.postMessage.mock.calls.slice(-3).map(call => call[0].command)).toEqual(['next', 'previous', 'shuffle']);
|
expect(child.postMessage.mock.calls.slice(-3).map(call => call[0].command)).toEqual(['next', 'previous', 'shuffle']);
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { mount } from '@vue/test-utils'
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
import { defineComponent, nextTick, ref } from 'vue'
|
import { defineComponent, nextTick, ref } from 'vue'
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { useCompanionAudio } from '../useCompanionAudio'
|
import { useCompanionAudio } from '../useCompanionAudio'
|
||||||
import type { useAudioPlayer } from '../useAudioPlayer'
|
import type { useAudioPlayer } from '../useAudioPlayer'
|
||||||
|
|
||||||
beforeEach(() => vi.useFakeTimers())
|
beforeEach(() => vi.useFakeTimers())
|
||||||
afterEach(() => { vi.useRealTimers(); delete (window as any).ArchipelagoAudio })
|
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals(); delete (window as any).ArchipelagoAudio })
|
||||||
function fixture(install = true) {
|
function fixture(install = true) {
|
||||||
const messages: any[] = []
|
const messages: any[] = []
|
||||||
const bridge: any = { onmessage: null, postMessage: (raw: string) => messages.push(JSON.parse(raw)) }
|
const bridge: any = { onmessage: null, postMessage: (raw: string) => messages.push(JSON.parse(raw)) }
|
||||||
if (install) (window as any).ArchipelagoAudio = bridge
|
if (install) (window as any).ArchipelagoAudio = bridge
|
||||||
const player = {
|
const player = {
|
||||||
currentSrc: ref<string | null>('https://node.test/private?token=secret'), currentName: ref('A song'),
|
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(''),
|
playing: ref(true), currentTime: ref(12), duration: ref(120), externalArtwork: ref(''), externalArtworkOrigin: ref(''),
|
||||||
externalApp: ref(true), externalShuffle: ref(false), error: ref<string | null>(null),
|
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(),
|
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 }),
|
stop: vi.fn(() => { player.currentSrc.value = null; player.playing.value = false }),
|
||||||
@@ -81,6 +81,44 @@ describe('companion native audio control of the existing player', () => {
|
|||||||
f.wrapper.unmount(); const count = f.messages.length
|
f.wrapper.unmount(); const count = f.messages.length
|
||||||
await vi.advanceTimersByTimeAsync(30000); expect(f.messages).toHaveLength(count)
|
await vi.advanceTimersByTimeAsync(30000); expect(f.messages).toHaveLength(count)
|
||||||
})
|
})
|
||||||
|
it('decodes artwork from the admitted HTTP app port without forwarding cookies or following redirects', async () => {
|
||||||
|
const jpeg = 'data:image/jpeg;base64,/9j/fixture';
|
||||||
|
const fetchImage = vi.fn(async () => ({ ok: true, headers: new Headers({ 'content-type': 'image/jpeg' }),
|
||||||
|
body: new ReadableStream({ start(controller) { controller.enqueue(new Uint8Array([255, 216, 255])); controller.close() } }) }))
|
||||||
|
vi.stubGlobal('fetch', fetchImage)
|
||||||
|
vi.stubGlobal('createImageBitmap', vi.fn(async () => ({ close: vi.fn() })))
|
||||||
|
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ drawImage: vi.fn() } as any)
|
||||||
|
vi.spyOn(HTMLCanvasElement.prototype, 'toDataURL').mockReturnValue(jpeg)
|
||||||
|
const f = fixture(); await nextTick()
|
||||||
|
f.player.externalArtworkOrigin.value = 'http://node.test:7475'
|
||||||
|
f.player.externalArtwork.value = 'http://node.test:7475/cover.jpg'
|
||||||
|
await nextTick(); await flushPromises()
|
||||||
|
expect(fetchImage).toHaveBeenCalledWith('http://node.test:7475/cover.jpg', expect.objectContaining({ credentials: 'same-origin', redirect: 'error', referrerPolicy: 'no-referrer' }))
|
||||||
|
expect(f.messages.some(value => value.artwork === jpeg)).toBe(true)
|
||||||
|
expect(JSON.stringify(f.messages)).not.toContain('/cover.jpg')
|
||||||
|
f.wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('rejects arbitrary HTTP artwork and credentials even with an admitted app origin', async () => {
|
||||||
|
const fetchImage = vi.fn(); vi.stubGlobal('fetch', fetchImage)
|
||||||
|
const f = fixture(); await nextTick()
|
||||||
|
f.player.externalArtworkOrigin.value = 'http://node.test:7475'
|
||||||
|
for (const url of ['http://other.test/image.jpg', 'http://node.test:7476/image.jpg', 'http://user:password@node.test:7475/image.jpg']) {
|
||||||
|
f.player.externalArtwork.value = url; await nextTick(); await flushPromises()
|
||||||
|
}
|
||||||
|
expect(fetchImage).not.toHaveBeenCalled()
|
||||||
|
expect(f.player.pause).not.toHaveBeenCalled()
|
||||||
|
f.wrapper.unmount()
|
||||||
|
})
|
||||||
|
it('treats a denied redirect or image-fetch failure as optional without pausing music', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('redirect denied')))
|
||||||
|
const f = fixture(); await nextTick()
|
||||||
|
f.player.externalArtworkOrigin.value = 'http://node.test:7475'
|
||||||
|
f.player.externalArtwork.value = 'http://node.test:7475/redirect'
|
||||||
|
await nextTick(); await flushPromises()
|
||||||
|
expect(f.player.pause).not.toHaveBeenCalled()
|
||||||
|
expect(f.messages[f.messages.length - 1]).toMatchObject({ playing: true })
|
||||||
|
f.wrapper.unmount()
|
||||||
|
})
|
||||||
it('does not revive paused sessions and reports native start failures without leaking details', async () => {
|
it('does not revive paused sessions and reports native start failures without leaking details', async () => {
|
||||||
const f = fixture(); await nextTick()
|
const f = fixture(); await nextTick()
|
||||||
f.bridge.onmessage({ data: JSON.stringify({ version: 1, session: f.messages[0].session, type: 'error', error: 'sensitive details' }) })
|
f.bridge.onmessage({ data: JSON.stringify({ version: 1, session: f.messages[0].session, type: 'error', error: 'sensitive details' }) })
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export function useAppMediaBridge(appId: Ref<string>, url: Ref<string>, frame: R
|
|||||||
}
|
}
|
||||||
admitted.source.postMessage({ type: 'archipelago:media-connect', version: 1, session }, admitted.origin)
|
admitted.source.postMessage({ type: 'archipelago:media-connect', version: 1, session }, admitted.origin)
|
||||||
}
|
}
|
||||||
const controller = { id, play: () => command('play'), pause: () => command('pause'), seek: (position: number) => command('seek', position),
|
const controller = { id, get artworkOrigin() { return admitted?.origin || '' }, play: () => command('play'), pause: () => command('pause'), seek: (position: number) => command('seek', position),
|
||||||
next: () => command('next'), previous: () => command('previous'), shuffle: () => command('shuffle'),
|
next: () => command('next'), previous: () => command('previous'), shuffle: () => command('shuffle'),
|
||||||
open: () => launcher.openSession(appId.value), close: () => {
|
open: () => launcher.openSession(appId.value), close: () => {
|
||||||
if (launcher.mediaAppId === appId.value && launcher.panelAppId !== appId.value) launcher.mediaAppId = null
|
if (launcher.mediaAppId === appId.value && launcher.panelAppId !== appId.value) launcher.mediaAppId = null
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ let initialized = false
|
|||||||
export interface ExternalAudioState { title: string; artist?: string; playing: boolean; position: number; duration: number; artwork?: string; shuffle?: boolean }
|
export interface ExternalAudioState { title: string; artist?: string; playing: boolean; position: number; duration: number; artwork?: string; shuffle?: boolean }
|
||||||
export interface ExternalAudioController {
|
export interface ExternalAudioController {
|
||||||
id: string
|
id: string
|
||||||
|
/** Origin admitted by the host media bridge, never taken from a media-state payload. */
|
||||||
|
artworkOrigin?: string
|
||||||
play: () => void
|
play: () => void
|
||||||
pause: () => void
|
pause: () => void
|
||||||
seek: (position: number) => void
|
seek: (position: number) => void
|
||||||
@@ -27,6 +29,7 @@ const suppressedExternal = new Set<string>()
|
|||||||
const externalVisible = ref(false)
|
const externalVisible = ref(false)
|
||||||
const externalApp = ref(false)
|
const externalApp = ref(false)
|
||||||
const externalArtwork = ref('')
|
const externalArtwork = ref('')
|
||||||
|
const externalArtworkOrigin = ref('')
|
||||||
const externalShuffle = ref(false)
|
const externalShuffle = ref(false)
|
||||||
function nextExternal() { external?.next?.() }
|
function nextExternal() { external?.next?.() }
|
||||||
function previousExternal() { external?.previous?.() }
|
function previousExternal() { external?.previous?.() }
|
||||||
@@ -41,6 +44,7 @@ function updateExternal(controller: ExternalAudioController, state: ExternalAudi
|
|||||||
external = controller
|
external = controller
|
||||||
}
|
}
|
||||||
externalArtwork.value = state.artwork || ''
|
externalArtwork.value = state.artwork || ''
|
||||||
|
externalArtworkOrigin.value = controller.artworkOrigin || ''
|
||||||
externalShuffle.value = state.shuffle === true
|
externalShuffle.value = state.shuffle === true
|
||||||
externalApp.value = true
|
externalApp.value = true
|
||||||
externalVisible.value = visible
|
externalVisible.value = visible
|
||||||
@@ -56,6 +60,7 @@ function detachExternal(id: string) {
|
|||||||
if (external?.id !== id) return
|
if (external?.id !== id) return
|
||||||
external = null
|
external = null
|
||||||
externalArtwork.value = ''
|
externalArtwork.value = ''
|
||||||
|
externalArtworkOrigin.value = ''
|
||||||
externalShuffle.value = false
|
externalShuffle.value = false
|
||||||
externalApp.value = false
|
externalApp.value = false
|
||||||
externalVisible.value = false
|
externalVisible.value = false
|
||||||
@@ -181,7 +186,7 @@ const progress = computed(() => {
|
|||||||
|
|
||||||
export function useAudioPlayer() {
|
export function useAudioPlayer() {
|
||||||
return {
|
return {
|
||||||
externalArtwork, externalShuffle, nextExternal, previousExternal, shuffleExternal,
|
externalArtwork, externalArtworkOrigin, externalShuffle, nextExternal, previousExternal, shuffleExternal,
|
||||||
updateExternal, detachExternal, releaseExternal, setExternalVisible, openExternal, externalVisible, externalApp,
|
updateExternal, detachExternal, releaseExternal, setExternalVisible, openExternal, externalVisible, externalApp,
|
||||||
play,
|
play,
|
||||||
pause,
|
pause,
|
||||||
|
|||||||
@@ -14,11 +14,14 @@ function audioSessionId(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Send a small decoded thumbnail, never a protected URL or authorization data. */
|
/** Send a small decoded thumbnail, never a protected URL or authorization data. */
|
||||||
async function thumbnail(url: string, signal: AbortSignal): Promise<string> {
|
async function thumbnail(url: string, admittedOrigin: string, signal: AbortSignal): Promise<string> {
|
||||||
if (!url) return ''
|
if (!url) return ''
|
||||||
const source = new URL(url, window.location.href)
|
const source = new URL(url, window.location.href)
|
||||||
if (source.username || source.password || (source.protocol !== 'https:' && source.origin !== window.location.origin)) return ''
|
// An app's authenticated media origin can use a different port on LAN HTTP.
|
||||||
const response = await fetch(source.href, { signal, credentials: 'same-origin', referrerPolicy: 'no-referrer' })
|
// 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 ''
|
if (!response.ok || !response.headers.get('content-type')?.startsWith('image/')) return ''
|
||||||
const reader = response.body?.getReader()
|
const reader = response.body?.getReader()
|
||||||
if (!reader) return ''
|
if (!reader) return ''
|
||||||
@@ -113,7 +116,7 @@ export function useCompanionAudio(player = useAudioPlayer()) {
|
|||||||
imageRequest?.abort(); artwork = ''
|
imageRequest?.abort(); artwork = ''
|
||||||
if (!session || !player.externalArtwork.value) return
|
if (!session || !player.externalArtwork.value) return
|
||||||
const target = session; const request = new AbortController(); imageRequest = request
|
const target = session; const request = new AbortController(); imageRequest = request
|
||||||
void thumbnail(player.externalArtwork.value, request.signal).then(value => {
|
void thumbnail(player.externalArtwork.value, player.externalArtworkOrigin.value, request.signal).then(value => {
|
||||||
if (!disposed && !request.signal.aborted && target === session) { artwork = value; publish() }
|
if (!disposed && !request.signal.aborted && target === session) { artwork = value; publish() }
|
||||||
}).catch(() => { /* Optional image failure never interrupts music. */ })
|
}).catch(() => { /* Optional image failure never interrupts music. */ })
|
||||||
}
|
}
|
||||||
@@ -122,7 +125,7 @@ export function useCompanionAudio(player = useAudioPlayer()) {
|
|||||||
watch([player.currentTime, player.duration], () => {
|
watch([player.currentTime, player.duration], () => {
|
||||||
if (!timer) timer = setTimeout(() => { timer = undefined; publish() }, 1000)
|
if (!timer) timer = setTimeout(() => { timer = undefined; publish() }, 1000)
|
||||||
})
|
})
|
||||||
watch(player.externalArtwork, () => { loadArtwork(); publish() })
|
watch([player.externalArtwork, player.externalArtworkOrigin], () => { loadArtwork(); publish() })
|
||||||
const sync = () => publish()
|
const sync = () => publish()
|
||||||
window.addEventListener('pageshow', sync)
|
window.addEventListener('pageshow', sync)
|
||||||
document.addEventListener('visibilitychange', sync)
|
document.addEventListener('visibilitychange', sync)
|
||||||
|
|||||||
Reference in New Issue
Block a user