Add native music queue controls, artwork and settled V4V banner

This commit is contained in:
archipelago
2026-10-06 17:52:33 -04:00
parent e3775771ca
commit 07de8de380
9 changed files with 131 additions and 6 deletions
+43
View File
@@ -0,0 +1,43 @@
# V4V native player and music banner follow-up
## Source changes
- The existing native player now offers previous, next and shuffle. Each command
delegates to V4V's existing action, so V4V owns the queue and shuffle ordering.
Shuffle state and the current cover come back from the same app snapshot.
- Cover artwork sits under a dark contrast layer; malformed or credential-bearing
URLs are ignored. Remote artwork requires HTTPS. No media URL, payment token or
second audio element is handed to the dashboard.
- The app iframe remains mounted while hidden. The app panel and bottom bar have
complementary transitions, disabled for reduced-motion preferences.
- V4V's same-host default-port media handshake survives a same-app login redirect
or absent referrer. Parent window, exact origin and per-session nonce checks
remain required. Unrelated host/sibling-port referrers are rejected.
- The Sovereign Music banner uses the app's final settled cymatic field, not an
early intro frame: the original pattern, zero offset, particle lift 0.88 and
`renderFrame(0)`, matching `setSettledPattern`. The capture uses a fixed random
seed and the app's actual `cymatics.mjs`. A decorative record/play symbol and
waveform appear on the right, subdued on small screens.
## Validation
- App media protocol: 4 tests passed, including login redirect boundaries and
delegation of all three queue controls.
- Dashboard bridge/audio/catalog: 18 tests passed.
- Native player rendering: 1 test passed, including cover, authoritative shuffle,
control delegation, visibility on reopen and absence of a second audio element.
- Vue type check passed.
- Real Chromium component rendering at 390 and 1440 pixels: all six buttons
visible; previous/next/shuffle reached the controller; no horizontal overflow.
Reduced-motion rendering exercised. This is an isolated component fixture,
not live app integration acceptance.
- The operator reported that the existing bottom-bar transition is now working.
This is separate from the automated evidence above.
## Still required after deployment
On Yaya, play a real song, close the app, operate previous/next/shuffle from the
native bar, and reopen the same iframe. Verify uninterrupted audio, matching
song/cover/shuffle, and the app's actual queue. Repeat at mobile and desktop
sizes and through the new Nostr login. Verify banner visually with the signed
node-only catalog. This follow-up does not claim those live checks passed yet.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 418 KiB

After

Width:  |  Height:  |  Size: 214 KiB

+15 -3
View File
@@ -6,9 +6,11 @@
ref="barEl"
class="fixed left-0 right-0 z-40 audio-player-bar"
>
<img v-if="audioPlayer.externalArtwork.value" :src="audioPlayer.externalArtwork.value" alt="" class="player-artwork" referrerpolicy="no-referrer" />
<div class="player-artwork-shade" aria-hidden="true" />
<!-- Progress bar (clickable) -->
<div
class="h-1 bg-white/10 cursor-pointer"
class="relative h-1 bg-white/10 cursor-pointer"
@click="onProgressClick"
>
<div
@@ -17,7 +19,8 @@
/>
</div>
<div class="flex items-center gap-3 px-4 py-2.5">
<div class="player-controls relative flex items-center gap-2 px-3 py-2.5">
<button v-if="audioPlayer.externalApp.value" class="player-control" aria-label="Previous track" @click="audioPlayer.previousExternal()"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 5h2v14H6zm13 0v14L9 12z" /></svg></button>
<!-- Play/Pause -->
<button
class="flex-shrink-0 w-9 h-9 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center transition-colors"
@@ -36,6 +39,7 @@
</svg>
</button>
<button v-if="audioPlayer.externalApp.value" class="player-control" aria-label="Next track" @click="audioPlayer.nextExternal()"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M16 5h2v14h-2zM5 5l10 7-10 7z" /></svg></button>
<!-- Track info -->
<div class="flex-1 min-w-0">
<p v-if="audioPlayer.error.value" class="text-sm text-red-400 truncate">{{ audioPlayer.error.value }}</p>
@@ -43,7 +47,8 @@
<p class="text-xs text-white/40">{{ formatTime(audioPlayer.currentTime.value) }} / {{ formatTime(audioPlayer.duration.value) }}</p>
</div>
<button v-if="audioPlayer.externalApp.value" class="glass-button px-3 py-2 rounded-lg text-sm text-white shrink-0" @click="audioPlayer.openExternal()" aria-label="Open playing app">Open app</button>
<button v-if="audioPlayer.externalApp.value" class="player-control" aria-label="Shuffle" :aria-pressed="audioPlayer.externalShuffle.value" @click="audioPlayer.shuffleExternal()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="m17 3 4 4-4 4M3 17h3c4 0 8-10 12-10h3M17 13l4 4-4 4M3 7h3c1.5 0 3 1.4 4.5 3.3M14 14c1.4 1.7 2.7 3 4 3h3" /></svg></button>
<button v-if="audioPlayer.externalApp.value" class="glass-button px-3 py-2 rounded-lg text-sm text-white shrink-0" @click="audioPlayer.openExternal()" aria-label="Open playing app"><span class="hidden sm:inline">Open app</span><svg class="w-5 h-5 sm:hidden" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M14 3h7v7m0-7L10 14M9 3H3v18h18v-6" /></svg></button>
<!-- Close -->
<button
@@ -120,6 +125,13 @@ function formatTime(seconds: number): string {
</script>
<style scoped>
.player-artwork { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; opacity: .45; pointer-events: none; }
.player-artwork-shade { position: absolute; inset: 0; background: rgba(8,10,14,.65); pointer-events: none; }
.player-control { width: 36px; height: 44px; display: grid; place-items: center; flex-shrink: 0; color: white; border-radius: 8px; }
.player-control svg { width: 20px; height: 20px; }
.player-control[aria-pressed="true"] { color: #fb923c; background: rgba(251,146,60,.15); }
@media (max-width: 400px) { .player-controls { gap: 2px; padding-inline: 6px; } .player-control { width: 32px; } }
@media (prefers-reduced-motion: reduce) { .slide-up-enter-active, .slide-up-leave-active { transition: none !important; } }
.audio-player-bar {
/* Sit directly above the mobile tab bar (its height is published as
--mobile-tab-bar-height). On desktop the tab bar is hidden so the variable
@@ -0,0 +1,26 @@
import { mount } from '@vue/test-utils'
import { ref, nextTick } from 'vue'
import { describe, expect, it, vi } from 'vitest'
const fixture = vi.hoisted(() => ({ player: {} as any }))
vi.mock('@/composables/useAudioPlayer', () => ({ useAudioPlayer: () => fixture.player }))
import GlobalAudioPlayer from '../GlobalAudioPlayer.vue'
describe('native app player controls', () => {
it('renders app artwork and synchronized controls without a second audio source', async () => {
fixture.player = {
currentName: ref('Song · Artist'), externalVisible: ref(false), externalApp: ref(true), externalArtwork: ref('https://node.test:7475/media/cover.jpg'), 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'),
previousExternal: vi.fn(), nextExternal: vi.fn(), shuffleExternal: vi.fn(), openExternal: vi.fn(), pause: vi.fn(), play: vi.fn(), stop: vi.fn(), seek: vi.fn(),
}
const wrapper = mount(GlobalAudioPlayer, { global: { stubs: { teleport: true } } })
expect(wrapper.find('img').attributes('src')).toBe('https://node.test:7475/media/cover.jpg')
expect(wrapper.find('[aria-label="Shuffle"]').attributes('aria-pressed')).toBe('true')
for (const [label, action] of [['Previous track', 'previousExternal'], ['Next track', 'nextExternal'], ['Shuffle', 'shuffleExternal'], ['Open playing app', 'openExternal'], ['Pause audio', 'pause']]) {
await wrapper.find(`[aria-label="${label}"]`).trigger('click'); expect(fixture.player[action!]).toHaveBeenCalledOnce()
}
expect(wrapper.find('audio').exists()).toBe(false)
fixture.player.externalShuffle.value = false; await nextTick(); expect(wrapper.find('[aria-label="Shuffle"]').attributes('aria-pressed')).toBe('false')
fixture.player.externalVisible.value = true; await nextTick(); expect(wrapper.find('.audio-player-bar').exists()).toBe(false)
fixture.player.externalVisible.value = false; await nextTick(); expect(wrapper.find('.audio-player-bar').exists()).toBe(true)
wrapper.unmount()
})
})
@@ -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))
+14 -1
View File
@@ -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,
+6
View File
@@ -103,9 +103,11 @@
/>
</div>
</Transition>
<Transition name="panel-slide">
<div v-if="appLauncher.mediaAppId" v-show="appLauncher.panelAppId === appLauncher.mediaAppId" class="app-panel-container">
<AppSession :key="appLauncher.mediaAppId" :app-id-prop="appLauncher.mediaAppId" :suspended="appLauncher.panelAppId !== appLauncher.mediaAppId" @close="appLauncher.closePanel()" />
</div>
</Transition>
</main>
<!-- Persistent Mobile Tabs + Bottom Tab Bar — outside <main> so position:fixed isn't broken by will-change:transform -->
@@ -403,3 +405,7 @@ async function handleLogout() {
</script>
<!-- Styles extracted to dashboard/dashboard-styles.css -->
<style scoped>
@media (prefers-reduced-motion: reduce) { .panel-slide-enter-active, .panel-slide-leave-active { transition: none !important; } }
</style>
+14
View File
@@ -224,6 +224,10 @@
class="featured-banner-img source-banner-img"
@error="(e: Event) => (e.target as HTMLImageElement).style.display = 'none'"
/>
<div v-if="promotion.id === 'node-demo-v4v'" class="music-promotion-art" aria-hidden="true">
<div class="music-promotion-disc"><svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="46" /><circle cx="50" cy="50" r="37" /><circle cx="50" cy="50" r="28" /><path d="m43 35 24 15-24 15z" /></svg></div>
<div class="music-promotion-wave"><i v-for="(height, index) in [12, 24, 40, 28, 56, 36, 64, 44, 28, 48, 32, 16]" :key="index" :style="{ height: height + 'px' }" /></div>
</div>
<div class="featured-banner-overlay source-banner-overlay">
<div class="flex items-center gap-3 mb-2">
<span class="discover-terminal-tag">{{ promotion.eyebrow }}</span>
@@ -810,3 +814,13 @@ onMounted(() => {
// same shared-key resources).
defineExpose({ loadCommunityMarketplace, loadBitcoinPruneStatus })
</script>
<style scoped>
.music-promotion-art { position: absolute; inset: 0 3% 0 auto; width: 30%; display: flex; align-items: center; justify-content: center; pointer-events: none; opacity: .9; }
.music-promotion-disc { width: min(190px, 75%); aspect-ratio: 1; border-radius: 50%; background: linear-gradient(135deg, rgba(255,255,255,.15), rgba(0,0,0,.3)); box-shadow: 0 14px 60px #0008; backdrop-filter: blur(4px); transform: rotate(-12deg); }
.music-promotion-disc svg { width: 100%; height: 100%; fill: none; stroke: #ffffff35; stroke-width: .7; }
.music-promotion-disc path { fill: #fff; stroke: none; }
.music-promotion-wave { display: flex; align-items: center; gap: 4px; position: absolute; bottom: 12%; right: 0; transform: rotate(-8deg); }
.music-promotion-wave i { width: 4px; border-radius: 4px; background: linear-gradient(#fff, #ffffff30); }
@media (max-width: 640px) { .music-promotion-art { width: 120px; top: 0; bottom: auto; height: 120px; opacity: .35; right: -12px; } .music-promotion-wave { display: none; } }
</style>