Add audio playback features with AudioBubble and AudioPlayer components; implement starry background effect in VoidCanvas
This commit is contained in:
60
app/page.tsx
60
app/page.tsx
@@ -1,23 +1,57 @@
|
||||
'use client';
|
||||
"use client"
|
||||
|
||||
import { dateToUnix, useNostrEvents } from "nostr-react";
|
||||
import { useRef } from "react";
|
||||
import { useRef, useState } from "react"
|
||||
import { dateToUnix, useNostrEvents } from "nostr-react"
|
||||
import AudioBubble from "@/components/audio-bubble"
|
||||
import VoidCanvas from "@/components/void-canvas"
|
||||
import AudioPlayer from "@/components/audio-player"
|
||||
|
||||
export default function Home() {
|
||||
const now = useRef(new Date()); // Make sure current time isn't re-rendered
|
||||
const now = useRef(new Date()) // Make sure current time isn't re-rendered
|
||||
const [selectedAudio, setSelectedAudio] = useState<string | null>(null)
|
||||
const [playingEventId, setPlayingEventId] = useState<string | null>(null)
|
||||
|
||||
// Fetch audio events according to NIP-94
|
||||
const { events } = useNostrEvents({
|
||||
filter: {
|
||||
since: dateToUnix(now.current), // all new events from now
|
||||
kinds: [1],
|
||||
// since: dateToUnix(now.current), // all new events from now
|
||||
kinds: [1063], // NIP-94 audio events
|
||||
// "#m": ["audio/mpeg", "audio/ogg", "audio/wav", "audio/webm"], // Audio MIME types
|
||||
"#m": ["audio/mpeg", "audio/ogg", "audio/wav"], // Audio MIME types
|
||||
},
|
||||
});
|
||||
})
|
||||
|
||||
const handleBubbleClick = (url: string, eventId: string) => {
|
||||
setSelectedAudio(url)
|
||||
setPlayingEventId(eventId)
|
||||
}
|
||||
|
||||
const handleAudioEnd = () => {
|
||||
setSelectedAudio(null)
|
||||
setPlayingEventId(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{events.map((event) => (
|
||||
<p key={event.id}>{event.pubkey} posted: {event.content}</p>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
<div className="relative w-full h-screen bg-black overflow-hidden">
|
||||
<VoidCanvas>
|
||||
{events.map((event) => {
|
||||
// Extract audio URL from NIP-94 event
|
||||
const url = event.tags.find((tag) => tag[0] === "url")?.[1]
|
||||
if (!url) return null
|
||||
|
||||
return (
|
||||
<AudioBubble
|
||||
key={event.id}
|
||||
eventId={event.id}
|
||||
url={url}
|
||||
isPlaying={event.id === playingEventId}
|
||||
onClick={handleBubbleClick}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</VoidCanvas>
|
||||
|
||||
{selectedAudio && <AudioPlayer url={selectedAudio} onEnded={handleAudioEnd} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
86
components/audio-bubble.tsx
Normal file
86
components/audio-bubble.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { Music } from "lucide-react"
|
||||
|
||||
interface AudioBubbleProps {
|
||||
eventId: string
|
||||
url: string
|
||||
isPlaying: boolean
|
||||
onClick: (url: string, eventId: string) => void
|
||||
}
|
||||
|
||||
export default function AudioBubble({ eventId, url, isPlaying, onClick }: AudioBubbleProps) {
|
||||
const bubbleRef = useRef<HTMLDivElement>(null)
|
||||
const [position, setPosition] = useState({
|
||||
x: Math.random() * 80 + 10, // 10-90% of screen width
|
||||
y: Math.random() * 80 + 10, // 10-90% of screen height
|
||||
})
|
||||
const [velocity, setVelocity] = useState({
|
||||
x: (Math.random() - 0.5) * 0.2, // Random direction
|
||||
y: (Math.random() - 0.5) * 0.2, // Random direction
|
||||
})
|
||||
const [size] = useState(Math.random() * 40 + 60) // 60-100px
|
||||
|
||||
// Handle bubble movement
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setPosition((prev) => {
|
||||
// Calculate new position
|
||||
let newX = prev.x + velocity.x
|
||||
let newY = prev.y + velocity.y
|
||||
|
||||
// Bounce off edges
|
||||
if (newX < 0 || newX > 100) {
|
||||
setVelocity((prev) => ({ ...prev, x: -prev.x }))
|
||||
newX = prev.x
|
||||
}
|
||||
|
||||
if (newY < 0 || newY > 100) {
|
||||
setVelocity((prev) => ({ ...prev, y: -prev.y }))
|
||||
newY = prev.y
|
||||
}
|
||||
|
||||
return { x: newX, y: newY }
|
||||
})
|
||||
}, 50)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [velocity])
|
||||
|
||||
// Add slight random movement changes
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setVelocity((prev) => ({
|
||||
x: prev.x + (Math.random() - 0.5) * 0.05,
|
||||
y: prev.y + (Math.random() - 0.5) * 0.05,
|
||||
}))
|
||||
}, 2000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const handleClick = () => {
|
||||
onClick(url, eventId)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={bubbleRef}
|
||||
className={`absolute rounded-full flex items-center justify-center cursor-pointer transition-all duration-300 ${
|
||||
isPlaying ? "bg-purple-500 shadow-lg shadow-purple-500/50" : "bg-blue-500/70 hover:bg-blue-400/80"
|
||||
}`}
|
||||
style={{
|
||||
left: `${position.x}%`,
|
||||
top: `${position.y}%`,
|
||||
width: `${size}px`,
|
||||
height: `${size}px`,
|
||||
transform: "translate(-50%, -50%)",
|
||||
zIndex: isPlaying ? 10 : 1,
|
||||
}}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Music className={`${isPlaying ? "animate-pulse text-white" : "text-white/80"}`} size={size * 0.4} />
|
||||
</div>
|
||||
)
|
||||
}
|
36
components/audio-player.tsx
Normal file
36
components/audio-player.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef } from "react"
|
||||
|
||||
interface AudioPlayerProps {
|
||||
url: string
|
||||
onEnded: () => void
|
||||
}
|
||||
|
||||
export default function AudioPlayer({ url, onEnded }: AudioPlayerProps) {
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioRef.current) {
|
||||
audioRef.current = new Audio(url)
|
||||
audioRef.current.addEventListener("ended", onEnded)
|
||||
audioRef.current.play().catch((error) => {
|
||||
console.error("Error playing audio:", error)
|
||||
})
|
||||
} else {
|
||||
audioRef.current.src = url
|
||||
audioRef.current.play().catch((error) => {
|
||||
console.error("Error playing audio:", error)
|
||||
})
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause()
|
||||
audioRef.current.removeEventListener("ended", onEnded)
|
||||
}
|
||||
}
|
||||
}, [url, onEnded])
|
||||
|
||||
return null // Audio player is invisible
|
||||
}
|
73
components/void-canvas.tsx
Normal file
73
components/void-canvas.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client"
|
||||
|
||||
import type React from "react"
|
||||
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
interface VoidCanvasProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export default function VoidCanvas({ children }: VoidCanvasProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
|
||||
// Create starry background effect
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const createStar = () => {
|
||||
const star = document.createElement("div")
|
||||
star.className = "absolute rounded-full bg-white opacity-70"
|
||||
|
||||
// Random size between 1-3px
|
||||
const size = Math.random() * 2 + 1
|
||||
star.style.width = `${size}px`
|
||||
star.style.height = `${size}px`
|
||||
|
||||
// Random position
|
||||
star.style.left = `${Math.random() * 100}%`
|
||||
star.style.top = `${Math.random() * 100}%`
|
||||
|
||||
// Random twinkle animation
|
||||
star.style.animation = `twinkle ${Math.random() * 5 + 3}s infinite`
|
||||
|
||||
container.appendChild(star)
|
||||
|
||||
// Remove after some time to prevent too many stars
|
||||
setTimeout(() => {
|
||||
container.removeChild(star)
|
||||
}, 8000)
|
||||
}
|
||||
|
||||
// Create initial stars
|
||||
for (let i = 0; i < 50; i++) {
|
||||
createStar()
|
||||
}
|
||||
|
||||
// Add new stars periodically
|
||||
const interval = setInterval(() => {
|
||||
createStar()
|
||||
}, 300)
|
||||
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="absolute inset-0 bg-black overflow-hidden">
|
||||
<style jsx global>{`
|
||||
@keyframes twinkle {
|
||||
0%, 100% { opacity: 0.3; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{mounted && children}
|
||||
</div>
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user