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 {