Add native music queue controls, artwork and settled V4V banner
This commit is contained in:
@@ -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 |
@@ -6,9 +6,11 @@
|
|||||||
ref="barEl"
|
ref="barEl"
|
||||||
class="fixed left-0 right-0 z-40 audio-player-bar"
|
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) -->
|
<!-- Progress bar (clickable) -->
|
||||||
<div
|
<div
|
||||||
class="h-1 bg-white/10 cursor-pointer"
|
class="relative h-1 bg-white/10 cursor-pointer"
|
||||||
@click="onProgressClick"
|
@click="onProgressClick"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -17,7 +19,8 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 -->
|
<!-- Play/Pause -->
|
||||||
<button
|
<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"
|
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>
|
</svg>
|
||||||
</button>
|
</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 -->
|
<!-- Track info -->
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<p v-if="audioPlayer.error.value" class="text-sm text-red-400 truncate">{{ audioPlayer.error.value }}</p>
|
<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>
|
<p class="text-xs text-white/40">{{ formatTime(audioPlayer.currentTime.value) }} / {{ formatTime(audioPlayer.duration.value) }}</p>
|
||||||
</div>
|
</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 -->
|
<!-- Close -->
|
||||||
<button
|
<button
|
||||||
@@ -120,6 +125,13 @@ function formatTime(seconds: number): string {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<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 {
|
.audio-player-bar {
|
||||||
/* Sit directly above the mobile tab bar (its height is published as
|
/* 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
|
--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()
|
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 }
|
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)
|
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 })
|
||||||
@@ -32,6 +32,9 @@ 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(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)
|
expect(snapshot.position).toBe(22); expect(shown).toBe(false)
|
||||||
controller.seek(30)
|
controller.seek(30)
|
||||||
expect(child.postMessage.mock.calls[child.postMessage.mock.calls.length - 1]![0]).toMatchObject({ command: 'seek', position: 30, session: hello.session })
|
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())
|
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),
|
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: () => {
|
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
|
||||||
},
|
},
|
||||||
@@ -31,8 +32,15 @@ export function useAppMediaBridge(appId: Ref<string>, url: Ref<string>, frame: R
|
|||||||
if (data.available !== true) { player.detachExternal(id); return }
|
if (data.available !== true) { player.detachExternal(id); return }
|
||||||
if (typeof data.title !== 'string' || typeof data.artist !== 'string' || typeof data.playing !== 'boolean'
|
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
|
|| !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,
|
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)
|
player.updateExternal(controller, state, visible.value)
|
||||||
}
|
}
|
||||||
watch(visible, shown => player.setExternalVisible(id, shown))
|
watch(visible, shown => player.setExternalVisible(id, shown))
|
||||||
|
|||||||
@@ -10,12 +10,15 @@ const duration = ref(0)
|
|||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
let initialized = false
|
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 {
|
export interface ExternalAudioController {
|
||||||
id: string
|
id: string
|
||||||
play: () => void
|
play: () => void
|
||||||
pause: () => void
|
pause: () => void
|
||||||
seek: (position: number) => void
|
seek: (position: number) => void
|
||||||
|
next?: () => void
|
||||||
|
previous?: () => void
|
||||||
|
shuffle?: () => void
|
||||||
open: () => void
|
open: () => void
|
||||||
close: () => void
|
close: () => void
|
||||||
}
|
}
|
||||||
@@ -23,6 +26,11 @@ let external: ExternalAudioController | null = null
|
|||||||
const suppressedExternal = new Set<string>()
|
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 externalShuffle = ref(false)
|
||||||
|
function nextExternal() { external?.next?.() }
|
||||||
|
function previousExternal() { external?.previous?.() }
|
||||||
|
function shuffleExternal() { external?.shuffle?.() }
|
||||||
|
|
||||||
function updateExternal(controller: ExternalAudioController, state: ExternalAudioState, visible: boolean) {
|
function updateExternal(controller: ExternalAudioController, state: ExternalAudioState, visible: boolean) {
|
||||||
if (suppressedExternal.has(controller.id)) { if (!state.playing) suppressedExternal.delete(controller.id); return }
|
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() }
|
if (external) { suppressedExternal.add(external.id); external.pause() }
|
||||||
external = controller
|
external = controller
|
||||||
}
|
}
|
||||||
|
externalArtwork.value = state.artwork || ''
|
||||||
|
externalShuffle.value = state.shuffle === true
|
||||||
externalApp.value = true
|
externalApp.value = true
|
||||||
externalVisible.value = visible
|
externalVisible.value = visible
|
||||||
currentSrc.value = `app:${controller.id}`
|
currentSrc.value = `app:${controller.id}`
|
||||||
@@ -45,6 +55,8 @@ function updateExternal(controller: ExternalAudioController, state: ExternalAudi
|
|||||||
function detachExternal(id: string) {
|
function detachExternal(id: string) {
|
||||||
if (external?.id !== id) return
|
if (external?.id !== id) return
|
||||||
external = null
|
external = null
|
||||||
|
externalArtwork.value = ''
|
||||||
|
externalShuffle.value = false
|
||||||
externalApp.value = false
|
externalApp.value = false
|
||||||
externalVisible.value = false
|
externalVisible.value = false
|
||||||
currentSrc.value = null
|
currentSrc.value = null
|
||||||
@@ -169,6 +181,7 @@ const progress = computed(() => {
|
|||||||
|
|
||||||
export function useAudioPlayer() {
|
export function useAudioPlayer() {
|
||||||
return {
|
return {
|
||||||
|
externalArtwork, externalShuffle, nextExternal, previousExternal, shuffleExternal,
|
||||||
updateExternal, detachExternal, releaseExternal, setExternalVisible, openExternal, externalVisible, externalApp,
|
updateExternal, detachExternal, releaseExternal, setExternalVisible, openExternal, externalVisible, externalApp,
|
||||||
play,
|
play,
|
||||||
pause,
|
pause,
|
||||||
|
|||||||
@@ -103,9 +103,11 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
|
<Transition name="panel-slide">
|
||||||
<div v-if="appLauncher.mediaAppId" v-show="appLauncher.panelAppId === appLauncher.mediaAppId" class="app-panel-container">
|
<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()" />
|
<AppSession :key="appLauncher.mediaAppId" :app-id-prop="appLauncher.mediaAppId" :suspended="appLauncher.panelAppId !== appLauncher.mediaAppId" @close="appLauncher.closePanel()" />
|
||||||
</div>
|
</div>
|
||||||
|
</Transition>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<!-- Persistent Mobile Tabs + Bottom Tab Bar — outside <main> so position:fixed isn't broken by will-change:transform -->
|
<!-- 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>
|
</script>
|
||||||
|
|
||||||
<!-- Styles extracted to dashboard/dashboard-styles.css -->
|
<!-- 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>
|
||||||
|
|||||||
@@ -224,6 +224,10 @@
|
|||||||
class="featured-banner-img source-banner-img"
|
class="featured-banner-img source-banner-img"
|
||||||
@error="(e: Event) => (e.target as HTMLImageElement).style.display = 'none'"
|
@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="featured-banner-overlay source-banner-overlay">
|
||||||
<div class="flex items-center gap-3 mb-2">
|
<div class="flex items-center gap-3 mb-2">
|
||||||
<span class="discover-terminal-tag">{{ promotion.eyebrow }}</span>
|
<span class="discover-terminal-tag">{{ promotion.eyebrow }}</span>
|
||||||
@@ -810,3 +814,13 @@ onMounted(() => {
|
|||||||
// same shared-key resources).
|
// same shared-key resources).
|
||||||
defineExpose({ loadCommunityMarketplace, loadBitcoinPruneStatus })
|
defineExpose({ loadCommunityMarketplace, loadBitcoinPruneStatus })
|
||||||
</script>
|
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user