From 9e95f847b10251a76931612cebe6cc3805d1139b Mon Sep 17 00:00:00 2001
From: Johnny322
Date: Sun, 8 Feb 2026 15:33:31 +0100
Subject: [PATCH] Add pulsing music player
---
src/App.vue | 151 ++++++++++++++++++++++++++++++++++++++++++++++++-
src/styles.css | 80 +++++++++++++++++++++++---
2 files changed, 222 insertions(+), 9 deletions(-)
diff --git a/src/App.vue b/src/App.vue
index 242da0c..300fe53 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -154,11 +154,31 @@
+
+
+
+
+
+ Status
+ {{ isPlaying ? 'Playing' : 'Paused' }}
+
+
+
Pick a tile to start the round.
@@ -234,7 +254,15 @@ export default {
currentTileKey: null,
currentClipUrl: '',
currentSelectorId: null,
- lastAwardedTeamId: null
+ lastAwardedTeamId: null,
+ isPlaying: false,
+ pulseLevel: 0,
+ audioContext: null as AudioContext | null,
+ analyser: null as AnalyserNode | null,
+ analyserData: null as Uint8Array | null,
+ rafId: 0,
+ mediaSource: null as MediaElementAudioSourceNode | null,
+ mediaElement: null as HTMLAudioElement | null
}
},
computed: {
@@ -258,10 +286,122 @@ export default {
.join(', ')
}
},
+ watch: {
+ currentClipUrl(newValue: string) {
+ if (!newValue) {
+ this.teardownAudio()
+ return
+ }
+ nextTick(() => this.setupAudioGraph())
+ }
+ },
methods: {
getPlayer() {
return this.$refs.player as HTMLAudioElement | undefined
},
+ ensureAudioContext() {
+ if (!this.audioContext) {
+ this.audioContext = new AudioContext()
+ }
+ if (this.audioContext.state === 'suspended') {
+ this.audioContext.resume().catch(() => {})
+ }
+ },
+ setupAudioGraph() {
+ const player = this.getPlayer()
+ if (!player) return
+ this.ensureAudioContext()
+
+ if (this.analyser) {
+ this.analyser.disconnect()
+ this.analyser = null
+ }
+
+ const context = this.audioContext
+ if (!context) return
+
+ if (this.mediaElement !== player) {
+ if (this.mediaSource) {
+ this.mediaSource.disconnect()
+ }
+ this.mediaSource = context.createMediaElementSource(player)
+ this.mediaElement = player
+ }
+
+ const analyser = context.createAnalyser()
+ analyser.fftSize = 1024
+ this.mediaSource?.connect(analyser)
+ analyser.connect(context.destination)
+
+ this.analyser = analyser
+ this.analyserData = new Uint8Array(analyser.fftSize)
+ this.pulseLevel = 0
+ },
+ teardownAudio() {
+ if (this.rafId) {
+ cancelAnimationFrame(this.rafId)
+ this.rafId = 0
+ }
+ if (this.analyser) {
+ this.analyser.disconnect()
+ this.analyser = null
+ }
+ if (this.mediaSource) {
+ this.mediaSource.disconnect()
+ this.mediaSource = null
+ }
+ this.mediaElement = null
+ this.analyserData = null
+ this.isPlaying = false
+ this.pulseLevel = 0
+ },
+ startPulse() {
+ if (!this.analyser || !this.analyserData) return
+ const analyser = this.analyser
+ const data = this.analyserData
+
+ const tick = () => {
+ if (!this.isPlaying || !this.analyser || !this.analyserData) {
+ this.rafId = 0
+ return
+ }
+ analyser.getByteTimeDomainData(data)
+ let sumSquares = 0
+ for (let i = 0; i < data.length; i += 1) {
+ const centered = (data[i] - 128) / 128
+ sumSquares += centered * centered
+ }
+ const rms = Math.sqrt(sumSquares / data.length)
+ const target = Math.min(1, rms * 2.8)
+ this.pulseLevel = this.pulseLevel * 0.7 + target * 0.3
+ this.rafId = requestAnimationFrame(tick)
+ }
+
+ if (this.rafId) cancelAnimationFrame(this.rafId)
+ this.rafId = requestAnimationFrame(tick)
+ },
+ handlePlayerPlay() {
+ this.isPlaying = true
+ this.startPulse()
+ },
+ handlePlayerPause() {
+ this.isPlaying = false
+ if (this.rafId) {
+ cancelAnimationFrame(this.rafId)
+ this.rafId = 0
+ }
+ this.pulseLevel = 0
+ },
+ togglePlayback() {
+ const player = this.getPlayer()
+ if (!player) return
+ this.ensureAudioContext()
+ if (player.paused) {
+ player.play().catch(() => {})
+ } else {
+ player.pause()
+ }
+ },
teamGradient(team: Team) {
return {
background: `linear-gradient(135deg, ${team.colorA}, ${team.colorB})`
@@ -295,6 +435,7 @@ export default {
this.currentTileKey = null
this.currentClipUrl = ''
this.lastAwardedTeamId = null
+ this.teardownAudio()
},
tileKey(cIndex: number, qIndex: number) {
return `${cIndex}-${qIndex}`
@@ -341,6 +482,7 @@ export default {
await nextTick()
const player = this.getPlayer()
if (player) {
+ this.ensureAudioContext()
player.currentTime = 0
player.load()
player.play().catch(() => {})
@@ -363,6 +505,7 @@ export default {
await nextTick()
const player = this.getPlayer()
if (player) {
+ this.ensureAudioContext()
player.currentTime = 0
player.load()
player.play().catch(() => {})
@@ -400,6 +543,7 @@ export default {
this.currentClipUrl = ''
const player = this.getPlayer()
player?.pause()
+ this.teardownAudio()
this.checkEnd()
}
},
@@ -430,6 +574,9 @@ export default {
this.step = 'end'
}
}
+ },
+ unmounted() {
+ this.teardownAudio()
}
}
diff --git a/src/styles.css b/src/styles.css
index 33e4a37..8030a5a 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -368,15 +368,81 @@ button {
min-height: 220px;
}
+audio.hidden-audio {
+ display: none;
+}
-audio {
- width: 100%;
- max-height: 320px;
- border-radius: 12px;
- border: 1px solid rgba(255, 255, 255, 0.1);
- background: #000;
+.custom-player {
+ width: min(420px, 100%);
+ min-height: 200px;
+ border-radius: 20px;
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ background: radial-gradient(circle at top, rgba(61, 214, 255, 0.2), rgba(8, 12, 26, 0.9) 70%);
+ display: grid;
+ place-items: center;
+ gap: 16px;
+ padding: 20px;
position: relative;
- z-index: 30;
+ overflow: hidden;
+}
+
+.custom-player::after {
+ content: '';
+ position: absolute;
+ inset: 12px;
+ border-radius: 18px;
+ border: 1px solid rgba(255, 255, 255, 0.08);
+ pointer-events: none;
+}
+
+.pulse-orb {
+ width: 96px;
+ height: 96px;
+ border-radius: 50%;
+ background: radial-gradient(circle at 30% 30%, #fbd72b, #f58b2b 60%, #1d3b8b);
+ transform: scale(calc(1 + var(--pulse, 0) * 0.22));
+ box-shadow: 0 0 calc(20px + var(--pulse, 0) * 60px) rgba(251, 215, 43, 0.35);
+ transition: transform 0.08s ease-out;
+}
+
+.pulse-orb.active {
+ animation: pulseGlow 1.6s ease-in-out infinite;
+}
+
+.player-controls {
+ display: grid;
+ gap: 12px;
+ place-items: center;
+}
+
+.player-meta {
+ text-align: center;
+ font-size: 0.85rem;
+ color: rgba(255, 255, 255, 0.7);
+}
+
+.player-meta .label {
+ text-transform: uppercase;
+ letter-spacing: 0.2em;
+ font-size: 0.65rem;
+ display: block;
+}
+
+.player-meta .value {
+ display: block;
+ color: #f6f4ef;
+ font-weight: 600;
+ margin-top: 4px;
+}
+
+@keyframes pulseGlow {
+ 0%,
+ 100% {
+ box-shadow: 0 0 24px rgba(61, 214, 255, 0.25);
+ }
+ 50% {
+ box-shadow: 0 0 48px rgba(251, 215, 43, 0.35);
+ }
}
.player-empty {