Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
1402 lines
46 KiB
Markdown
1402 lines
46 KiB
Markdown
# CMusic 3D Frontend Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Build a supermodern 3D-animated music website frontend with React Three Fiber, Framer Motion, GSAP, and Tailwind CSS — cyberpunk-luxury aesthetic.
|
|
|
|
**Architecture:** Single-page React app with distinct section components each managing their own 3D canvas (via R3F) or animation layer (Framer Motion / GSAP). A global `useAudioPlayer` hook drives the persistent player bar state. Stitch MCP generates base UI screens; Context7 provides accurate library API docs.
|
|
|
|
**Tech Stack:** React 18, Vite 5, Tailwind CSS 3, @react-three/fiber, @react-three/drei, Framer Motion, GSAP (+ ScrollTrigger), Stitch MCP (UI generation)
|
|
|
|
---
|
|
|
|
## File Map
|
|
|
|
```
|
|
/srv/CMusic/
|
|
├── index.html
|
|
├── vite.config.js
|
|
├── tailwind.config.js
|
|
├── postcss.config.js
|
|
├── package.json
|
|
└── src/
|
|
├── main.jsx
|
|
├── App.jsx
|
|
├── index.css # global styles, CSS vars
|
|
├── data/
|
|
│ └── mockData.js # tracks, albums, artists
|
|
├── hooks/
|
|
│ └── useAudioPlayer.js # global player state
|
|
└── components/
|
|
├── Navbar/
|
|
│ └── Navbar.jsx
|
|
├── Hero/
|
|
│ ├── Hero.jsx
|
|
│ ├── AudioVisualizer3D.jsx # R3F bar visualizer
|
|
│ └── HeroParticles.jsx # R3F floating particles
|
|
├── FeaturedAlbums/
|
|
│ ├── FeaturedAlbums.jsx
|
|
│ └── AlbumCard3D.jsx # R3F card with flip
|
|
├── TrendingTracks/
|
|
│ ├── TrendingTracks.jsx
|
|
│ └── TrackRow.jsx
|
|
├── ArtistSpotlight/
|
|
│ ├── ArtistSpotlight.jsx
|
|
│ └── ArtistParticles.jsx # R3F particle field
|
|
└── Player/
|
|
├── PlayerBar.jsx
|
|
└── Waveform3D.jsx # R3F animated waveform
|
|
```
|
|
|
|
---
|
|
|
|
## Task 1: Scaffold Project
|
|
|
|
**Files:**
|
|
- Create: `package.json`, `vite.config.js`, `tailwind.config.js`, `postcss.config.js`, `index.html`, `src/main.jsx`
|
|
|
|
- [ ] **Step 1: Init Vite React project**
|
|
|
|
```bash
|
|
cd /srv/CMusic
|
|
npm create vite@latest . -- --template react
|
|
```
|
|
|
|
- [ ] **Step 2: Install all dependencies**
|
|
|
|
```bash
|
|
npm install
|
|
npm install three @react-three/fiber @react-three/drei
|
|
npm install framer-motion gsap
|
|
npm install tailwindcss@3 postcss autoprefixer
|
|
npm install @types/three
|
|
npx tailwindcss init -p
|
|
```
|
|
|
|
- [ ] **Step 3: Configure tailwind.config.js**
|
|
|
|
```js
|
|
/** @type {import('tailwindcss').Config} */
|
|
export default {
|
|
content: ['./index.html', './src/**/*.{js,jsx}'],
|
|
theme: {
|
|
extend: {
|
|
colors: {
|
|
'neon-purple': '#b829f5',
|
|
'neon-cyan': '#00f5ff',
|
|
'neon-pink': '#ff2d78',
|
|
'dark-base': '#050508',
|
|
'dark-card': '#0d0d18',
|
|
'dark-surface': '#12121f',
|
|
'glass': 'rgba(255,255,255,0.04)',
|
|
},
|
|
fontFamily: {
|
|
sans: ['Inter', 'system-ui', 'sans-serif'],
|
|
display: ['Space Grotesk', 'system-ui', 'sans-serif'],
|
|
},
|
|
backgroundImage: {
|
|
'gradient-neon': 'linear-gradient(135deg, #b829f5 0%, #00f5ff 100%)',
|
|
'gradient-dark': 'linear-gradient(180deg, #050508 0%, #0d0d18 100%)',
|
|
},
|
|
boxShadow: {
|
|
'neon-purple': '0 0 20px rgba(184,41,245,0.5), 0 0 60px rgba(184,41,245,0.2)',
|
|
'neon-cyan': '0 0 20px rgba(0,245,255,0.5), 0 0 60px rgba(0,245,255,0.2)',
|
|
'glass': 'inset 0 1px 0 rgba(255,255,255,0.1)',
|
|
},
|
|
},
|
|
},
|
|
plugins: [],
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Configure vite.config.js**
|
|
|
|
```js
|
|
import { defineConfig } from 'vite'
|
|
import react from '@vitejs/plugin-react'
|
|
|
|
export default defineConfig({
|
|
plugins: [react()],
|
|
})
|
|
```
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git init
|
|
git add .
|
|
git commit -m "feat: scaffold Vite+React+R3F+Tailwind project"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Fetch Docs via Context7
|
|
|
|
**Purpose:** Get accurate API signatures for React Three Fiber, Framer Motion, GSAP ScrollTrigger before writing 3D/animation code.
|
|
|
|
- [ ] **Step 1: Resolve Context7 library IDs**
|
|
|
|
Use `mcp__context7__resolve-library-id` with queries:
|
|
- `"react-three-fiber 3D scene"`
|
|
- `"framer-motion animation"`
|
|
- `"gsap scroll trigger"`
|
|
|
|
- [ ] **Step 2: Query R3F docs**
|
|
|
|
Use `mcp__context7__query-docs` with resolved ID and question:
|
|
`"How to create a Canvas with useFrame animation loop and mesh geometry in React Three Fiber?"`
|
|
|
|
- [ ] **Step 3: Query Framer Motion docs**
|
|
|
|
Use `mcp__context7__query-docs` with question:
|
|
`"How to use motion.div with variants, whileHover, and useInView scroll animations?"`
|
|
|
|
- [ ] **Step 4: Query GSAP ScrollTrigger docs**
|
|
|
|
Use `mcp__context7__query-docs` with question:
|
|
`"How to use GSAP ScrollTrigger with React useEffect for parallax and scrub animations?"`
|
|
|
|
---
|
|
|
|
## Task 3: Global Styles + Design System
|
|
|
|
**Files:**
|
|
- Modify: `src/index.css`
|
|
- Use Stitch MCP to generate design system
|
|
|
|
- [ ] **Step 1: Create Stitch design system**
|
|
|
|
Call `mcp__stitch__create_design_system` with:
|
|
```json
|
|
{
|
|
"name": "CMusic Design System",
|
|
"description": "Cyberpunk-luxury dark theme. Deep black backgrounds (#050508), electric purple (#b829f5) and cyan (#00f5ff) neon accents, glassmorphism cards with backdrop-blur, Space Grotesk display font, Inter body. Neon glow box-shadows. No white backgrounds anywhere."
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Write src/index.css**
|
|
|
|
```css
|
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Space+Grotesk:wght@400;500;600;700&display=swap');
|
|
@tailwind base;
|
|
@tailwind components;
|
|
@tailwind utilities;
|
|
|
|
:root {
|
|
--neon-purple: #b829f5;
|
|
--neon-cyan: #00f5ff;
|
|
--neon-pink: #ff2d78;
|
|
--dark-base: #050508;
|
|
--dark-card: #0d0d18;
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
|
|
html { scroll-behavior: smooth; }
|
|
|
|
body {
|
|
background-color: var(--dark-base);
|
|
color: #e8e8f0;
|
|
font-family: 'Inter', sans-serif;
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
::-webkit-scrollbar { width: 4px; }
|
|
::-webkit-scrollbar-track { background: var(--dark-base); }
|
|
::-webkit-scrollbar-thumb {
|
|
background: var(--neon-purple);
|
|
border-radius: 2px;
|
|
}
|
|
|
|
.glass {
|
|
background: rgba(255, 255, 255, 0.04);
|
|
backdrop-filter: blur(20px);
|
|
-webkit-backdrop-filter: blur(20px);
|
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
}
|
|
|
|
.neon-text-purple {
|
|
color: var(--neon-purple);
|
|
text-shadow: 0 0 10px rgba(184,41,245,0.8);
|
|
}
|
|
|
|
.neon-text-cyan {
|
|
color: var(--neon-cyan);
|
|
text-shadow: 0 0 10px rgba(0,245,255,0.8);
|
|
}
|
|
|
|
.neon-border {
|
|
border: 1px solid rgba(184,41,245,0.4);
|
|
box-shadow: 0 0 15px rgba(184,41,245,0.2), inset 0 0 15px rgba(184,41,245,0.05);
|
|
}
|
|
|
|
canvas { display: block; }
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/index.css tailwind.config.js
|
|
git commit -m "feat: global styles and cyberpunk design system"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Mock Data + useAudioPlayer Hook
|
|
|
|
**Files:**
|
|
- Create: `src/data/mockData.js`
|
|
- Create: `src/hooks/useAudioPlayer.js`
|
|
|
|
- [ ] **Step 1: Write mockData.js**
|
|
|
|
```js
|
|
export const tracks = [
|
|
{ id: 1, title: 'Neon Pulse', artist: 'Synthwave X', album: 'Electric Dreams', duration: '3:42', plays: '2.1M', cover: 'https://picsum.photos/seed/track1/400/400' },
|
|
{ id: 2, title: 'Void Walker', artist: 'Dark Matter', album: 'Singularity', duration: '4:15', plays: '1.8M', cover: 'https://picsum.photos/seed/track2/400/400' },
|
|
{ id: 3, title: 'Cyber Rain', artist: 'Neo Tokyo', album: 'Ghost Protocol', duration: '3:58', plays: '3.4M', cover: 'https://picsum.photos/seed/track3/400/400' },
|
|
{ id: 4, title: 'Quantum Drift', artist: 'Parallax', album: 'Infinite Loop', duration: '5:02', plays: '987K', cover: 'https://picsum.photos/seed/track4/400/400' },
|
|
{ id: 5, title: 'Aurora Blaze', artist: 'Synthwave X', album: 'Electric Dreams', duration: '4:33', plays: '1.2M', cover: 'https://picsum.photos/seed/track5/400/400' },
|
|
{ id: 6, title: 'Digital Soul', artist: 'Echo System', album: 'Phase Shift', duration: '3:21', plays: '756K', cover: 'https://picsum.photos/seed/track6/400/400' },
|
|
];
|
|
|
|
export const albums = [
|
|
{ id: 1, title: 'Electric Dreams', artist: 'Synthwave X', year: 2024, genre: 'Synthwave', cover: 'https://picsum.photos/seed/album1/600/600', tracks: 12, color: '#b829f5' },
|
|
{ id: 2, title: 'Singularity', artist: 'Dark Matter', year: 2024, genre: 'Dark Electro', cover: 'https://picsum.photos/seed/album2/600/600', tracks: 9, color: '#00f5ff' },
|
|
{ id: 3, title: 'Ghost Protocol', artist: 'Neo Tokyo', year: 2023, genre: 'Cyberpunk', cover: 'https://picsum.photos/seed/album3/600/600', tracks: 11, color: '#ff2d78' },
|
|
{ id: 4, title: 'Infinite Loop', artist: 'Parallax', year: 2024, genre: 'Ambient', cover: 'https://picsum.photos/seed/album4/600/600', tracks: 8, color: '#b829f5' },
|
|
];
|
|
|
|
export const artists = [
|
|
{ id: 1, name: 'Synthwave X', genre: 'Synthwave / Electro', followers: '4.2M', image: 'https://picsum.photos/seed/artist1/800/1000', bio: 'Pioneering the neon-drenched soundscapes of tomorrow.' },
|
|
{ id: 2, name: 'Dark Matter', genre: 'Dark Electro', followers: '2.8M', image: 'https://picsum.photos/seed/artist2/800/1000', bio: 'Where darkness meets digital frequency.' },
|
|
];
|
|
```
|
|
|
|
- [ ] **Step 2: Write useAudioPlayer.js**
|
|
|
|
```js
|
|
import { useState, useCallback } from 'react'
|
|
import { tracks } from '../data/mockData'
|
|
|
|
export function useAudioPlayer() {
|
|
const [currentTrack, setCurrentTrack] = useState(tracks[0])
|
|
const [isPlaying, setIsPlaying] = useState(false)
|
|
const [progress, setProgress] = useState(0)
|
|
const [volume, setVolume] = useState(0.8)
|
|
|
|
const play = useCallback((track) => {
|
|
setCurrentTrack(track)
|
|
setIsPlaying(true)
|
|
}, [])
|
|
|
|
const togglePlay = useCallback(() => {
|
|
setIsPlaying(p => !p)
|
|
}, [])
|
|
|
|
const next = useCallback(() => {
|
|
const idx = tracks.findIndex(t => t.id === currentTrack.id)
|
|
setCurrentTrack(tracks[(idx + 1) % tracks.length])
|
|
}, [currentTrack])
|
|
|
|
const prev = useCallback(() => {
|
|
const idx = tracks.findIndex(t => t.id === currentTrack.id)
|
|
setCurrentTrack(tracks[(idx - 1 + tracks.length) % tracks.length])
|
|
}, [currentTrack])
|
|
|
|
return { currentTrack, isPlaying, progress, volume, play, togglePlay, next, prev, setProgress, setVolume }
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/data/mockData.js src/hooks/useAudioPlayer.js
|
|
git commit -m "feat: mock data and audio player hook"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Navbar
|
|
|
|
**Files:**
|
|
- Create: `src/components/Navbar/Navbar.jsx`
|
|
|
|
- [ ] **Step 1: Generate Navbar screen via Stitch**
|
|
|
|
Call `mcp__stitch__create_project` then `mcp__stitch__generate_screen_from_text` with:
|
|
```
|
|
"Glassmorphism music app navbar. Dark background with backdrop-blur. Logo 'CMUSIC' in electric purple neon. Nav links: Discover, Artists, Albums, Charts. Right side: Search icon, notification bell, user avatar circle. Subtle purple neon bottom border that animates on scroll. Cyberpunk aesthetic."
|
|
```
|
|
|
|
- [ ] **Step 2: Write Navbar.jsx**
|
|
|
|
```jsx
|
|
import { useState, useEffect } from 'react'
|
|
import { motion } from 'framer-motion'
|
|
|
|
const navLinks = ['Discover', 'Artists', 'Albums', 'Charts']
|
|
|
|
export default function Navbar() {
|
|
const [scrolled, setScrolled] = useState(false)
|
|
|
|
useEffect(() => {
|
|
const handler = () => setScrolled(window.scrollY > 20)
|
|
window.addEventListener('scroll', handler)
|
|
return () => window.removeEventListener('scroll', handler)
|
|
}, [])
|
|
|
|
return (
|
|
<motion.nav
|
|
initial={{ y: -80, opacity: 0 }}
|
|
animate={{ y: 0, opacity: 1 }}
|
|
transition={{ duration: 0.6, ease: 'easeOut' }}
|
|
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
|
|
scrolled ? 'glass border-b border-purple-500/20' : 'bg-transparent'
|
|
}`}
|
|
>
|
|
<div className="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
|
|
{/* Logo */}
|
|
<motion.div
|
|
className="font-display font-bold text-xl tracking-widest neon-text-purple cursor-pointer"
|
|
whileHover={{ scale: 1.05 }}
|
|
>
|
|
CMUSIC
|
|
</motion.div>
|
|
|
|
{/* Links */}
|
|
<div className="hidden md:flex items-center gap-8">
|
|
{navLinks.map((link) => (
|
|
<motion.a
|
|
key={link}
|
|
href={`#${link.toLowerCase()}`}
|
|
className="text-sm text-gray-400 hover:text-white transition-colors relative group"
|
|
whileHover={{ color: '#b829f5' }}
|
|
>
|
|
{link}
|
|
<span className="absolute -bottom-1 left-0 w-0 h-px bg-gradient-to-r from-neon-purple to-neon-cyan group-hover:w-full transition-all duration-300" />
|
|
</motion.a>
|
|
))}
|
|
</div>
|
|
|
|
{/* Right Actions */}
|
|
<div className="flex items-center gap-4">
|
|
<motion.button
|
|
whileHover={{ scale: 1.1 }}
|
|
whileTap={{ scale: 0.95 }}
|
|
className="text-gray-400 hover:text-white transition-colors"
|
|
aria-label="Search"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
</svg>
|
|
</motion.button>
|
|
<motion.div
|
|
whileHover={{ scale: 1.1 }}
|
|
className="w-8 h-8 rounded-full bg-gradient-to-br from-neon-purple to-neon-cyan flex items-center justify-center text-xs font-bold cursor-pointer"
|
|
>
|
|
U
|
|
</motion.div>
|
|
</div>
|
|
</div>
|
|
</motion.nav>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/components/Navbar/
|
|
git commit -m "feat: glassmorphism navbar with scroll effect"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Hero Section with 3D Visualizer
|
|
|
|
**Files:**
|
|
- Create: `src/components/Hero/Hero.jsx`
|
|
- Create: `src/components/Hero/AudioVisualizer3D.jsx`
|
|
- Create: `src/components/Hero/HeroParticles.jsx`
|
|
|
|
- [ ] **Step 1: Write AudioVisualizer3D.jsx**
|
|
|
|
```jsx
|
|
import { useRef, useMemo } from 'react'
|
|
import { Canvas, useFrame } from '@react-three/fiber'
|
|
import * as THREE from 'three'
|
|
|
|
const BAR_COUNT = 64
|
|
|
|
function Bars({ isPlaying }) {
|
|
const groupRef = useRef()
|
|
const barsRef = useRef([])
|
|
const phases = useMemo(() => Array.from({ length: BAR_COUNT }, (_, i) => Math.random() * Math.PI * 2), [])
|
|
const speeds = useMemo(() => Array.from({ length: BAR_COUNT }, () => 0.8 + Math.random() * 1.5), [])
|
|
|
|
useFrame(({ clock }) => {
|
|
const t = clock.getElapsedTime()
|
|
barsRef.current.forEach((mesh, i) => {
|
|
if (!mesh) return
|
|
const base = isPlaying
|
|
? Math.abs(Math.sin(t * speeds[i] + phases[i])) * 1.8 + 0.1
|
|
: Math.abs(Math.sin(t * 0.3 + phases[i])) * 0.3 + 0.05
|
|
mesh.scale.y = THREE.MathUtils.lerp(mesh.scale.y, base, 0.12)
|
|
mesh.position.y = mesh.scale.y * 0.5
|
|
const hue = (i / BAR_COUNT) * 0.2 + 0.75 + Math.sin(t * 0.2) * 0.05
|
|
mesh.material.color.setHSL(hue, 1, 0.6)
|
|
mesh.material.emissive.setHSL(hue, 1, 0.3)
|
|
})
|
|
})
|
|
|
|
const bars = useMemo(() => {
|
|
return Array.from({ length: BAR_COUNT }, (_, i) => {
|
|
const angle = (i / BAR_COUNT) * Math.PI * 2
|
|
const radius = 3
|
|
return {
|
|
x: Math.cos(angle) * radius,
|
|
z: Math.sin(angle) * radius,
|
|
}
|
|
})
|
|
}, [])
|
|
|
|
return (
|
|
<group ref={groupRef} rotation={[0.1, 0, 0]}>
|
|
{bars.map((pos, i) => (
|
|
<mesh
|
|
key={i}
|
|
ref={el => (barsRef.current[i] = el)}
|
|
position={[pos.x, 0.5, pos.z]}
|
|
>
|
|
<boxGeometry args={[0.08, 1, 0.08]} />
|
|
<meshStandardMaterial
|
|
color="#b829f5"
|
|
emissive="#b829f5"
|
|
emissiveIntensity={0.8}
|
|
toneMapped={false}
|
|
/>
|
|
</mesh>
|
|
))}
|
|
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -0.05, 0]}>
|
|
<ringGeometry args={[2.8, 3.2, 64]} />
|
|
<meshStandardMaterial color="#00f5ff" emissive="#00f5ff" emissiveIntensity={0.5} toneMapped={false} />
|
|
</mesh>
|
|
</group>
|
|
)
|
|
}
|
|
|
|
function RotatingGroup({ isPlaying, children }) {
|
|
const groupRef = useRef()
|
|
useFrame((_, delta) => {
|
|
if (groupRef.current) {
|
|
groupRef.current.rotation.y += delta * (isPlaying ? 0.4 : 0.1)
|
|
}
|
|
})
|
|
return <group ref={groupRef}>{children}</group>
|
|
}
|
|
|
|
export default function AudioVisualizer3D({ isPlaying }) {
|
|
return (
|
|
<Canvas
|
|
camera={{ position: [0, 3, 8], fov: 50 }}
|
|
gl={{ antialias: true, alpha: true }}
|
|
style={{ background: 'transparent' }}
|
|
>
|
|
<ambientLight intensity={0.2} />
|
|
<pointLight position={[0, 5, 0]} intensity={2} color="#b829f5" />
|
|
<pointLight position={[5, 2, 5]} intensity={1} color="#00f5ff" />
|
|
<RotatingGroup isPlaying={isPlaying}>
|
|
<Bars isPlaying={isPlaying} />
|
|
</RotatingGroup>
|
|
</Canvas>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Write HeroParticles.jsx**
|
|
|
|
```jsx
|
|
import { useRef, useMemo } from 'react'
|
|
import { Canvas, useFrame } from '@react-three/fiber'
|
|
import * as THREE from 'three'
|
|
|
|
const PARTICLE_COUNT = 200
|
|
|
|
function Particles() {
|
|
const pointsRef = useRef()
|
|
const positions = useMemo(() => {
|
|
const arr = new Float32Array(PARTICLE_COUNT * 3)
|
|
for (let i = 0; i < PARTICLE_COUNT; i++) {
|
|
arr[i * 3] = (Math.random() - 0.5) * 20
|
|
arr[i * 3 + 1] = (Math.random() - 0.5) * 20
|
|
arr[i * 3 + 2] = (Math.random() - 0.5) * 20
|
|
}
|
|
return arr
|
|
}, [])
|
|
|
|
useFrame(({ clock }) => {
|
|
if (pointsRef.current) {
|
|
pointsRef.current.rotation.y = clock.getElapsedTime() * 0.04
|
|
pointsRef.current.rotation.x = Math.sin(clock.getElapsedTime() * 0.02) * 0.1
|
|
}
|
|
})
|
|
|
|
return (
|
|
<points ref={pointsRef}>
|
|
<bufferGeometry>
|
|
<bufferAttribute attach="attributes-position" args={[positions, 3]} />
|
|
</bufferGeometry>
|
|
<pointsMaterial size={0.04} color="#b829f5" transparent opacity={0.6} />
|
|
</points>
|
|
)
|
|
}
|
|
|
|
export default function HeroParticles() {
|
|
return (
|
|
<Canvas camera={{ position: [0, 0, 5] }} gl={{ alpha: true }} style={{ background: 'transparent' }}>
|
|
<Particles />
|
|
</Canvas>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Write Hero.jsx**
|
|
|
|
```jsx
|
|
import { useEffect, useRef, useState } from 'react'
|
|
import { motion } from 'framer-motion'
|
|
import { gsap } from 'gsap'
|
|
import AudioVisualizer3D from './AudioVisualizer3D'
|
|
import HeroParticles from './HeroParticles'
|
|
|
|
export default function Hero({ isPlaying, onTogglePlay }) {
|
|
const headingRef = useRef()
|
|
const subRef = useRef()
|
|
|
|
useEffect(() => {
|
|
const ctx = gsap.context(() => {
|
|
gsap.from(headingRef.current, { y: 60, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.3 })
|
|
gsap.from(subRef.current, { y: 40, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.6 })
|
|
})
|
|
return () => ctx.revert()
|
|
}, [])
|
|
|
|
return (
|
|
<section className="relative min-h-screen flex items-center justify-center overflow-hidden bg-dark-base">
|
|
{/* Particle background */}
|
|
<div className="absolute inset-0 pointer-events-none">
|
|
<HeroParticles />
|
|
</div>
|
|
|
|
{/* Gradient blobs */}
|
|
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-neon-purple/10 rounded-full blur-3xl pointer-events-none" />
|
|
<div className="absolute bottom-1/4 right-1/4 w-80 h-80 bg-neon-cyan/10 rounded-full blur-3xl pointer-events-none" />
|
|
|
|
{/* 3D Visualizer */}
|
|
<div className="absolute inset-0 pointer-events-none">
|
|
<AudioVisualizer3D isPlaying={isPlaying} />
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="relative z-10 text-center px-6 max-w-4xl mx-auto">
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.8 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
transition={{ duration: 0.5 }}
|
|
className="inline-block mb-4 px-4 py-1 rounded-full glass text-xs tracking-widest text-neon-cyan uppercase"
|
|
>
|
|
Now Streaming
|
|
</motion.div>
|
|
|
|
<h1
|
|
ref={headingRef}
|
|
className="font-display font-bold text-5xl md:text-8xl mb-6 leading-none"
|
|
>
|
|
<span className="text-white">Feel The</span>{' '}
|
|
<span className="neon-text-purple">Music</span>
|
|
<br />
|
|
<span className="bg-gradient-to-r from-neon-purple via-neon-pink to-neon-cyan bg-clip-text text-transparent">
|
|
In 3D
|
|
</span>
|
|
</h1>
|
|
|
|
<p ref={subRef} className="text-gray-400 text-lg md:text-xl mb-10 max-w-2xl mx-auto leading-relaxed">
|
|
Immerse yourself in a new dimension of music. Visualize every beat, every pulse, every frequency.
|
|
</p>
|
|
|
|
<div className="flex items-center justify-center gap-4">
|
|
<motion.button
|
|
whileHover={{ scale: 1.05, boxShadow: '0 0 30px rgba(184,41,245,0.6)' }}
|
|
whileTap={{ scale: 0.97 }}
|
|
onClick={onTogglePlay}
|
|
className="px-8 py-4 rounded-full bg-gradient-to-r from-neon-purple to-neon-pink text-white font-semibold text-sm tracking-wide"
|
|
>
|
|
{isPlaying ? '⏸ Pause Vibe' : '▶ Start Listening'}
|
|
</motion.button>
|
|
|
|
<motion.button
|
|
whileHover={{ scale: 1.05 }}
|
|
whileTap={{ scale: 0.97 }}
|
|
className="px-8 py-4 rounded-full glass neon-border text-white font-semibold text-sm tracking-wide"
|
|
>
|
|
Explore Now
|
|
</motion.button>
|
|
</div>
|
|
|
|
{/* Scroll indicator */}
|
|
<motion.div
|
|
animate={{ y: [0, 10, 0] }}
|
|
transition={{ repeat: Infinity, duration: 2 }}
|
|
className="absolute bottom-8 left-1/2 -translate-x-1/2 text-gray-600"
|
|
>
|
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 9l-7 7-7-7" />
|
|
</svg>
|
|
</motion.div>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/components/Hero/
|
|
git commit -m "feat: hero section with 3D audio visualizer and particles"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Featured Albums with 3D Cards
|
|
|
|
**Files:**
|
|
- Create: `src/components/FeaturedAlbums/FeaturedAlbums.jsx`
|
|
- Create: `src/components/FeaturedAlbums/AlbumCard3D.jsx`
|
|
|
|
- [ ] **Step 1: Generate screen via Stitch**
|
|
|
|
Call `mcp__stitch__generate_screen_from_text` with:
|
|
```
|
|
"Music app featured albums grid. Dark background. 4 album cards with 3D perspective tilt on hover. Each card: album cover image (square), album title, artist name, genre tag. Neon purple/cyan glow border on hover. Glassmorphism card background. 'Featured Albums' section heading with neon accent."
|
|
```
|
|
|
|
- [ ] **Step 2: Write AlbumCard3D.jsx**
|
|
|
|
```jsx
|
|
import { useRef } from 'react'
|
|
import { motion, useMotionValue, useSpring, useTransform } from 'framer-motion'
|
|
|
|
export default function AlbumCard3D({ album, onPlay }) {
|
|
const cardRef = useRef()
|
|
const x = useMotionValue(0)
|
|
const y = useMotionValue(0)
|
|
const rotateX = useSpring(useTransform(y, [-0.5, 0.5], [15, -15]), { stiffness: 300, damping: 30 })
|
|
const rotateY = useSpring(useTransform(x, [-0.5, 0.5], [-15, 15]), { stiffness: 300, damping: 30 })
|
|
|
|
const handleMouseMove = (e) => {
|
|
const rect = cardRef.current.getBoundingClientRect()
|
|
x.set((e.clientX - rect.left) / rect.width - 0.5)
|
|
y.set((e.clientY - rect.top) / rect.height - 0.5)
|
|
}
|
|
|
|
const handleMouseLeave = () => {
|
|
x.set(0)
|
|
y.set(0)
|
|
}
|
|
|
|
return (
|
|
<motion.div
|
|
ref={cardRef}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseLeave={handleMouseLeave}
|
|
style={{ rotateX, rotateY, transformStyle: 'preserve-3d', perspective: 800 }}
|
|
whileHover={{ scale: 1.04 }}
|
|
className="relative glass rounded-2xl overflow-hidden cursor-pointer group"
|
|
>
|
|
{/* Glow */}
|
|
<div
|
|
className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-500 rounded-2xl pointer-events-none"
|
|
style={{ boxShadow: `0 0 40px ${album.color}60, inset 0 0 40px ${album.color}10` }}
|
|
/>
|
|
|
|
{/* Cover */}
|
|
<div className="relative overflow-hidden aspect-square">
|
|
<img src={album.cover} alt={album.title} className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700" />
|
|
<div className="absolute inset-0 bg-gradient-to-t from-dark-card via-transparent to-transparent" />
|
|
|
|
{/* Play overlay */}
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
whileHover={{ opacity: 1 }}
|
|
className="absolute inset-0 flex items-center justify-center bg-black/40"
|
|
>
|
|
<motion.button
|
|
whileHover={{ scale: 1.15 }}
|
|
whileTap={{ scale: 0.9 }}
|
|
onClick={() => onPlay(album)}
|
|
className="w-14 h-14 rounded-full flex items-center justify-center"
|
|
style={{ background: album.color, boxShadow: `0 0 25px ${album.color}` }}
|
|
>
|
|
<span className="text-white text-xl ml-1">▶</span>
|
|
</motion.button>
|
|
</motion.div>
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div className="p-4">
|
|
<h3 className="font-display font-semibold text-white text-sm truncate">{album.title}</h3>
|
|
<p className="text-gray-400 text-xs mt-1">{album.artist}</p>
|
|
<div className="flex items-center justify-between mt-3">
|
|
<span className="text-xs px-2 py-0.5 rounded-full" style={{ background: `${album.color}20`, color: album.color }}>
|
|
{album.genre}
|
|
</span>
|
|
<span className="text-xs text-gray-600">{album.tracks} tracks</span>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Write FeaturedAlbums.jsx**
|
|
|
|
```jsx
|
|
import { useRef } from 'react'
|
|
import { motion, useInView } from 'framer-motion'
|
|
import AlbumCard3D from './AlbumCard3D'
|
|
import { albums } from '../../data/mockData'
|
|
|
|
export default function FeaturedAlbums({ onPlay }) {
|
|
const ref = useRef()
|
|
const inView = useInView(ref, { once: true, margin: '-100px' })
|
|
|
|
return (
|
|
<section id="albums" ref={ref} className="py-24 px-6 bg-dark-base relative overflow-hidden">
|
|
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-neon-purple/40 to-transparent" />
|
|
|
|
<div className="max-w-7xl mx-auto">
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 30 }}
|
|
animate={inView ? { opacity: 1, y: 0 } : {}}
|
|
transition={{ duration: 0.6 }}
|
|
className="mb-12"
|
|
>
|
|
<p className="text-neon-cyan text-xs tracking-widest uppercase mb-2">Collection</p>
|
|
<h2 className="font-display font-bold text-4xl md:text-5xl text-white">
|
|
Featured <span className="neon-text-purple">Albums</span>
|
|
</h2>
|
|
</motion.div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
{albums.map((album, i) => (
|
|
<motion.div
|
|
key={album.id}
|
|
initial={{ opacity: 0, y: 50 }}
|
|
animate={inView ? { opacity: 1, y: 0 } : {}}
|
|
transition={{ duration: 0.5, delay: i * 0.1 }}
|
|
>
|
|
<AlbumCard3D album={album} onPlay={onPlay} />
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/components/FeaturedAlbums/
|
|
git commit -m "feat: featured albums with 3D perspective tilt cards"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Trending Tracks with Parallax
|
|
|
|
**Files:**
|
|
- Create: `src/components/TrendingTracks/TrendingTracks.jsx`
|
|
- Create: `src/components/TrendingTracks/TrackRow.jsx`
|
|
|
|
- [ ] **Step 1: Write TrackRow.jsx**
|
|
|
|
```jsx
|
|
import { motion } from 'framer-motion'
|
|
|
|
export default function TrackRow({ track, index, isActive, onPlay }) {
|
|
return (
|
|
<motion.div
|
|
whileHover={{ x: 8, backgroundColor: 'rgba(184,41,245,0.06)' }}
|
|
className={`flex items-center gap-4 px-4 py-3 rounded-xl cursor-pointer transition-colors group ${
|
|
isActive ? 'bg-neon-purple/10 neon-border' : ''
|
|
}`}
|
|
onClick={() => onPlay(track)}
|
|
>
|
|
{/* Rank */}
|
|
<span className={`w-6 text-center text-sm font-mono ${isActive ? 'neon-text-purple' : 'text-gray-600'}`}>
|
|
{isActive ? '♫' : index + 1}
|
|
</span>
|
|
|
|
{/* Cover */}
|
|
<div className="relative w-12 h-12 rounded-lg overflow-hidden flex-shrink-0">
|
|
<img src={track.cover} alt={track.title} className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-300" />
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
whileHover={{ opacity: 1 }}
|
|
className="absolute inset-0 bg-black/50 flex items-center justify-center"
|
|
>
|
|
<span className="text-white text-sm">▶</span>
|
|
</motion.div>
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div className="flex-1 min-w-0">
|
|
<p className={`text-sm font-medium truncate ${isActive ? 'text-neon-purple' : 'text-white'}`}>{track.title}</p>
|
|
<p className="text-xs text-gray-500 truncate">{track.artist}</p>
|
|
</div>
|
|
|
|
{/* Plays */}
|
|
<span className="hidden sm:block text-xs text-gray-600">{track.plays}</span>
|
|
|
|
{/* Duration */}
|
|
<span className="text-xs text-gray-500 font-mono">{track.duration}</span>
|
|
|
|
{/* Active bar */}
|
|
{isActive && (
|
|
<div className="flex items-end gap-0.5 h-4">
|
|
{[1, 2, 3].map(i => (
|
|
<motion.div
|
|
key={i}
|
|
animate={{ scaleY: [1, 0.3, 1] }}
|
|
transition={{ repeat: Infinity, duration: 0.8, delay: i * 0.15 }}
|
|
className="w-0.5 bg-neon-purple rounded-full origin-bottom"
|
|
style={{ height: '100%' }}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Write TrendingTracks.jsx**
|
|
|
|
```jsx
|
|
import { useRef, useEffect } from 'react'
|
|
import { motion, useInView } from 'framer-motion'
|
|
import { gsap } from 'gsap'
|
|
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
|
import TrackRow from './TrackRow'
|
|
import { tracks } from '../../data/mockData'
|
|
|
|
gsap.registerPlugin(ScrollTrigger)
|
|
|
|
export default function TrendingTracks({ currentTrack, onPlay }) {
|
|
const sectionRef = useRef()
|
|
const ref = useRef()
|
|
const inView = useInView(ref, { once: true, margin: '-100px' })
|
|
|
|
useEffect(() => {
|
|
const ctx = gsap.context(() => {
|
|
gsap.from('.track-parallax', {
|
|
x: -40,
|
|
opacity: 0,
|
|
stagger: 0.08,
|
|
scrollTrigger: {
|
|
trigger: sectionRef.current,
|
|
start: 'top 80%',
|
|
end: 'bottom 20%',
|
|
},
|
|
})
|
|
}, sectionRef)
|
|
return () => ctx.revert()
|
|
}, [])
|
|
|
|
return (
|
|
<section id="charts" ref={sectionRef} className="py-24 px-6 bg-dark-surface relative">
|
|
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-neon-cyan/40 to-transparent" />
|
|
|
|
<div className="max-w-4xl mx-auto" ref={ref}>
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 30 }}
|
|
animate={inView ? { opacity: 1, y: 0 } : {}}
|
|
transition={{ duration: 0.6 }}
|
|
className="mb-12"
|
|
>
|
|
<p className="text-neon-purple text-xs tracking-widest uppercase mb-2">Hot Right Now</p>
|
|
<h2 className="font-display font-bold text-4xl md:text-5xl text-white">
|
|
Trending <span className="neon-text-cyan">Tracks</span>
|
|
</h2>
|
|
</motion.div>
|
|
|
|
<div className="space-y-1">
|
|
{tracks.map((track, i) => (
|
|
<div key={track.id} className="track-parallax">
|
|
<TrackRow
|
|
track={track}
|
|
index={i}
|
|
isActive={currentTrack?.id === track.id}
|
|
onPlay={onPlay}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/components/TrendingTracks/
|
|
git commit -m "feat: trending tracks section with GSAP parallax"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Artist Spotlight with R3F Particle Field
|
|
|
|
**Files:**
|
|
- Create: `src/components/ArtistSpotlight/ArtistSpotlight.jsx`
|
|
- Create: `src/components/ArtistSpotlight/ArtistParticles.jsx`
|
|
|
|
- [ ] **Step 1: Write ArtistParticles.jsx**
|
|
|
|
```jsx
|
|
import { useRef, useMemo } from 'react'
|
|
import { Canvas, useFrame } from '@react-three/fiber'
|
|
import * as THREE from 'three'
|
|
|
|
const COUNT = 300
|
|
|
|
function Particles() {
|
|
const meshRef = useRef()
|
|
const dummy = useMemo(() => new THREE.Object3D(), [])
|
|
const particles = useMemo(() => {
|
|
return Array.from({ length: COUNT }, (_, i) => ({
|
|
phi: Math.acos(-1 + (2 * i) / COUNT),
|
|
theta: Math.sqrt(COUNT * Math.PI) * (2 * i / COUNT) * Math.PI,
|
|
speed: 0.2 + Math.random() * 0.3,
|
|
radius: 2 + Math.random() * 1.5,
|
|
}))
|
|
}, [])
|
|
|
|
useFrame(({ clock }) => {
|
|
const t = clock.getElapsedTime()
|
|
particles.forEach((p, i) => {
|
|
const r = p.radius + Math.sin(t * p.speed + i) * 0.3
|
|
dummy.position.set(
|
|
r * Math.sin(p.phi) * Math.cos(p.theta + t * p.speed * 0.1),
|
|
r * Math.cos(p.phi),
|
|
r * Math.sin(p.phi) * Math.sin(p.theta + t * p.speed * 0.1)
|
|
)
|
|
dummy.scale.setScalar(0.015 + Math.abs(Math.sin(t + i)) * 0.01)
|
|
dummy.updateMatrix()
|
|
meshRef.current.setMatrixAt(i, dummy.matrix)
|
|
})
|
|
meshRef.current.instanceMatrix.needsUpdate = true
|
|
})
|
|
|
|
return (
|
|
<instancedMesh ref={meshRef} args={[null, null, COUNT]}>
|
|
<sphereGeometry args={[1, 4, 4]} />
|
|
<meshBasicMaterial color="#b829f5" />
|
|
</instancedMesh>
|
|
)
|
|
}
|
|
|
|
export default function ArtistParticles() {
|
|
return (
|
|
<Canvas camera={{ position: [0, 0, 6] }} gl={{ alpha: true }} style={{ background: 'transparent' }}>
|
|
<Particles />
|
|
</Canvas>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Write ArtistSpotlight.jsx**
|
|
|
|
```jsx
|
|
import { useRef } from 'react'
|
|
import { motion, useInView } from 'framer-motion'
|
|
import ArtistParticles from './ArtistParticles'
|
|
import { artists } from '../../data/mockData'
|
|
|
|
export default function ArtistSpotlight() {
|
|
const artist = artists[0]
|
|
const ref = useRef()
|
|
const inView = useInView(ref, { once: true, margin: '-100px' })
|
|
|
|
return (
|
|
<section id="artists" ref={ref} className="relative py-24 overflow-hidden bg-dark-base">
|
|
<div className="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-neon-pink/40 to-transparent" />
|
|
|
|
{/* Particle field */}
|
|
<div className="absolute inset-0 pointer-events-none">
|
|
<ArtistParticles />
|
|
</div>
|
|
|
|
{/* Gradient overlay */}
|
|
<div className="absolute inset-0 bg-gradient-to-r from-dark-base via-dark-base/60 to-transparent pointer-events-none" />
|
|
|
|
<div className="max-w-7xl mx-auto px-6 relative z-10">
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
|
{/* Text */}
|
|
<motion.div
|
|
initial={{ opacity: 0, x: -50 }}
|
|
animate={inView ? { opacity: 1, x: 0 } : {}}
|
|
transition={{ duration: 0.7 }}
|
|
>
|
|
<p className="text-neon-pink text-xs tracking-widest uppercase mb-4">Artist Spotlight</p>
|
|
<h2 className="font-display font-bold text-5xl md:text-7xl text-white mb-4 leading-none">
|
|
{artist.name}
|
|
</h2>
|
|
<p className="text-neon-cyan text-sm mb-4">{artist.genre}</p>
|
|
<p className="text-gray-400 text-base leading-relaxed mb-8 max-w-md">{artist.bio}</p>
|
|
|
|
<div className="flex items-center gap-8 mb-8">
|
|
<div>
|
|
<p className="font-display font-bold text-2xl neon-text-purple">{artist.followers}</p>
|
|
<p className="text-xs text-gray-600 mt-1">Followers</p>
|
|
</div>
|
|
<div className="w-px h-10 bg-gray-800" />
|
|
<div>
|
|
<p className="font-display font-bold text-2xl text-white">48</p>
|
|
<p className="text-xs text-gray-600 mt-1">Tracks</p>
|
|
</div>
|
|
<div className="w-px h-10 bg-gray-800" />
|
|
<div>
|
|
<p className="font-display font-bold text-2xl text-white">6</p>
|
|
<p className="text-xs text-gray-600 mt-1">Albums</p>
|
|
</div>
|
|
</div>
|
|
|
|
<motion.button
|
|
whileHover={{ scale: 1.05, boxShadow: '0 0 30px rgba(255,45,120,0.5)' }}
|
|
whileTap={{ scale: 0.97 }}
|
|
className="px-8 py-3 rounded-full bg-gradient-to-r from-neon-pink to-neon-purple text-white font-semibold text-sm tracking-wide"
|
|
>
|
|
Follow Artist
|
|
</motion.button>
|
|
</motion.div>
|
|
|
|
{/* Artist image */}
|
|
<motion.div
|
|
initial={{ opacity: 0, scale: 0.8 }}
|
|
animate={inView ? { opacity: 1, scale: 1 } : {}}
|
|
transition={{ duration: 0.7, delay: 0.2 }}
|
|
className="relative"
|
|
>
|
|
<div className="relative rounded-2xl overflow-hidden aspect-[4/5] max-w-sm mx-auto" style={{ boxShadow: '0 0 60px rgba(184,41,245,0.3)' }}>
|
|
<img src={artist.image} alt={artist.name} className="w-full h-full object-cover" />
|
|
<div className="absolute inset-0 bg-gradient-to-t from-dark-base/80 via-transparent to-transparent" />
|
|
</div>
|
|
</motion.div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/components/ArtistSpotlight/
|
|
git commit -m "feat: artist spotlight with R3F particle sphere"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Persistent Player Bar with 3D Waveform
|
|
|
|
**Files:**
|
|
- Create: `src/components/Player/Waveform3D.jsx`
|
|
- Create: `src/components/Player/PlayerBar.jsx`
|
|
|
|
- [ ] **Step 1: Write Waveform3D.jsx**
|
|
|
|
```jsx
|
|
import { useRef, useMemo } from 'react'
|
|
import { Canvas, useFrame } from '@react-three/fiber'
|
|
import * as THREE from 'three'
|
|
|
|
const BAR_COUNT = 32
|
|
|
|
function Waveform({ isPlaying }) {
|
|
const barsRef = useRef([])
|
|
const phases = useMemo(() => Array.from({ length: BAR_COUNT }, () => Math.random() * Math.PI * 2), [])
|
|
|
|
useFrame(({ clock }) => {
|
|
const t = clock.getElapsedTime()
|
|
barsRef.current.forEach((mesh, i) => {
|
|
if (!mesh) return
|
|
const target = isPlaying
|
|
? Math.abs(Math.sin(t * 3 + phases[i])) * 0.8 + 0.05
|
|
: 0.05 + Math.abs(Math.sin(t * 0.5 + phases[i])) * 0.1
|
|
mesh.scale.y = THREE.MathUtils.lerp(mesh.scale.y, target, 0.15)
|
|
mesh.position.y = mesh.scale.y * 0.5
|
|
})
|
|
})
|
|
|
|
return (
|
|
<group>
|
|
{Array.from({ length: BAR_COUNT }, (_, i) => (
|
|
<mesh
|
|
key={i}
|
|
ref={el => (barsRef.current[i] = el)}
|
|
position={[(i - BAR_COUNT / 2) * 0.07, 0.05, 0]}
|
|
>
|
|
<boxGeometry args={[0.05, 1, 0.05]} />
|
|
<meshBasicMaterial
|
|
color={i % 3 === 0 ? '#b829f5' : i % 3 === 1 ? '#00f5ff' : '#ff2d78'}
|
|
/>
|
|
</mesh>
|
|
))}
|
|
</group>
|
|
)
|
|
}
|
|
|
|
export default function Waveform3D({ isPlaying }) {
|
|
return (
|
|
<Canvas
|
|
camera={{ position: [0, 0.5, 2.5], fov: 40 }}
|
|
gl={{ alpha: true, antialias: false }}
|
|
style={{ background: 'transparent' }}
|
|
>
|
|
<Waveform isPlaying={isPlaying} />
|
|
</Canvas>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Write PlayerBar.jsx**
|
|
|
|
```jsx
|
|
import { motion, AnimatePresence } from 'framer-motion'
|
|
import Waveform3D from './Waveform3D'
|
|
|
|
export default function PlayerBar({ currentTrack, isPlaying, progress, volume, onToggle, onNext, onPrev, onSeek, onVolume }) {
|
|
if (!currentTrack) return null
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
<motion.div
|
|
initial={{ y: 100, opacity: 0 }}
|
|
animate={{ y: 0, opacity: 1 }}
|
|
className="fixed bottom-0 left-0 right-0 z-50 glass border-t border-white/10"
|
|
style={{ boxShadow: '0 -10px 40px rgba(184,41,245,0.15)' }}
|
|
>
|
|
<div className="max-w-7xl mx-auto px-4 h-20 flex items-center gap-4">
|
|
{/* Track info */}
|
|
<div className="flex items-center gap-3 w-52 flex-shrink-0">
|
|
<div className="relative w-12 h-12 rounded-lg overflow-hidden flex-shrink-0">
|
|
<img src={currentTrack.cover} alt={currentTrack.title} className="w-full h-full object-cover" />
|
|
{isPlaying && (
|
|
<div className="absolute inset-0 border-2 border-neon-purple rounded-lg animate-pulse" />
|
|
)}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<p className="text-white text-sm font-medium truncate">{currentTrack.title}</p>
|
|
<p className="text-gray-500 text-xs truncate">{currentTrack.artist}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Center: controls + waveform */}
|
|
<div className="flex-1 flex flex-col items-center gap-1">
|
|
{/* Controls */}
|
|
<div className="flex items-center gap-6">
|
|
<motion.button
|
|
whileHover={{ scale: 1.1 }}
|
|
whileTap={{ scale: 0.9 }}
|
|
onClick={onPrev}
|
|
className="text-gray-400 hover:text-white transition-colors"
|
|
>
|
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z" />
|
|
</svg>
|
|
</motion.button>
|
|
|
|
<motion.button
|
|
whileHover={{ scale: 1.1 }}
|
|
whileTap={{ scale: 0.9 }}
|
|
onClick={onToggle}
|
|
className="w-10 h-10 rounded-full flex items-center justify-center text-white"
|
|
style={{ background: 'linear-gradient(135deg, #b829f5, #00f5ff)', boxShadow: '0 0 20px rgba(184,41,245,0.5)' }}
|
|
>
|
|
{isPlaying ? (
|
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
|
|
</svg>
|
|
) : (
|
|
<svg className="w-4 h-4 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z" />
|
|
</svg>
|
|
)}
|
|
</motion.button>
|
|
|
|
<motion.button
|
|
whileHover={{ scale: 1.1 }}
|
|
whileTap={{ scale: 0.9 }}
|
|
onClick={onNext}
|
|
className="text-gray-400 hover:text-white transition-colors"
|
|
>
|
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M6 18l8.5-6L6 6v12zm2-8.14L11.03 12 8 14.14V9.86zM16 6h2v12h-2z" />
|
|
</svg>
|
|
</motion.button>
|
|
</div>
|
|
|
|
{/* Progress bar */}
|
|
<div className="w-full max-w-md flex items-center gap-2">
|
|
<span className="text-xs text-gray-600 font-mono w-8 text-right">0:00</span>
|
|
<div
|
|
className="flex-1 h-1 bg-gray-800 rounded-full cursor-pointer group"
|
|
onClick={(e) => {
|
|
const rect = e.currentTarget.getBoundingClientRect()
|
|
onSeek((e.clientX - rect.left) / rect.width)
|
|
}}
|
|
>
|
|
<div
|
|
className="h-full rounded-full relative"
|
|
style={{ width: `${progress * 100}%`, background: 'linear-gradient(90deg, #b829f5, #00f5ff)' }}
|
|
>
|
|
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 rounded-full bg-white opacity-0 group-hover:opacity-100 transition-opacity" />
|
|
</div>
|
|
</div>
|
|
<span className="text-xs text-gray-600 font-mono w-8">{currentTrack.duration}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right: waveform + volume */}
|
|
<div className="hidden lg:flex items-center gap-3 w-48 flex-shrink-0">
|
|
<div className="w-24 h-10">
|
|
<Waveform3D isPlaying={isPlaying} />
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<svg className="w-4 h-4 text-gray-500" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z" />
|
|
</svg>
|
|
<input
|
|
type="range"
|
|
min="0" max="1" step="0.01"
|
|
value={volume}
|
|
onChange={(e) => onVolume(parseFloat(e.target.value))}
|
|
className="w-20 accent-neon-purple"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add src/components/Player/
|
|
git commit -m "feat: persistent player bar with 3D waveform"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Assemble App + Run
|
|
|
|
**Files:**
|
|
- Modify: `src/App.jsx`
|
|
- Modify: `src/main.jsx`
|
|
|
|
- [ ] **Step 1: Write src/main.jsx**
|
|
|
|
```jsx
|
|
import { StrictMode } from 'react'
|
|
import { createRoot } from 'react-dom/client'
|
|
import './index.css'
|
|
import App from './App.jsx'
|
|
|
|
createRoot(document.getElementById('root')).render(
|
|
<StrictMode>
|
|
<App />
|
|
</StrictMode>
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 2: Write src/App.jsx**
|
|
|
|
```jsx
|
|
import { useState } from 'react'
|
|
import { useAudioPlayer } from './hooks/useAudioPlayer'
|
|
import Navbar from './components/Navbar/Navbar'
|
|
import Hero from './components/Hero/Hero'
|
|
import FeaturedAlbums from './components/FeaturedAlbums/FeaturedAlbums'
|
|
import TrendingTracks from './components/TrendingTracks/TrendingTracks'
|
|
import ArtistSpotlight from './components/ArtistSpotlight/ArtistSpotlight'
|
|
import PlayerBar from './components/Player/PlayerBar'
|
|
|
|
export default function App() {
|
|
const player = useAudioPlayer()
|
|
|
|
return (
|
|
<div className="min-h-screen bg-dark-base pb-20">
|
|
<Navbar />
|
|
<Hero isPlaying={player.isPlaying} onTogglePlay={player.togglePlay} />
|
|
<FeaturedAlbums onPlay={player.play} />
|
|
<TrendingTracks currentTrack={player.currentTrack} onPlay={player.play} />
|
|
<ArtistSpotlight />
|
|
<PlayerBar
|
|
currentTrack={player.currentTrack}
|
|
isPlaying={player.isPlaying}
|
|
progress={player.progress}
|
|
volume={player.volume}
|
|
onToggle={player.togglePlay}
|
|
onNext={player.next}
|
|
onPrev={player.prev}
|
|
onSeek={player.setProgress}
|
|
onVolume={player.setVolume}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Update index.html with correct title**
|
|
|
|
```html
|
|
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>CMusic — Feel The Music In 3D</title>
|
|
</head>
|
|
<body>
|
|
<div id="root"></div>
|
|
<script type="module" src="/src/main.jsx"></script>
|
|
</body>
|
|
</html>
|
|
```
|
|
|
|
- [ ] **Step 4: Start dev server and verify**
|
|
|
|
```bash
|
|
npm run dev
|
|
```
|
|
Expected: Server starts at http://localhost:5173. Verify all sections render, 3D visualizer animates, player bar appears, album cards tilt on hover.
|
|
|
|
- [ ] **Step 5: Final commit**
|
|
|
|
```bash
|
|
git add src/App.jsx src/main.jsx index.html
|
|
git commit -m "feat: assemble full CMusic 3D music website"
|
|
```
|