Initial commit: ACE-Step UI - Open source music generation interface
@@ -0,0 +1,25 @@
|
||||
# ACE-Step UI Configuration
|
||||
|
||||
# Server
|
||||
PORT=3001
|
||||
NODE_ENV=development
|
||||
|
||||
# Database (SQLite)
|
||||
DATABASE_PATH=./data/acestep.db
|
||||
|
||||
# ACE-Step API (local)
|
||||
ACESTEP_API_URL=http://localhost:8001
|
||||
|
||||
# Storage
|
||||
AUDIO_DIR=./public/audio
|
||||
|
||||
# Frontend
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
VITE_API_URL=http://localhost:3001
|
||||
|
||||
# JWT Secret (for local session management)
|
||||
JWT_SECRET=ace-step-ui-local-secret
|
||||
|
||||
# Optional: Pexels API Key (for video backgrounds)
|
||||
# Get a free API key at https://www.pexels.com/api/
|
||||
PEXELS_API_KEY=
|
||||
@@ -0,0 +1,92 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
server/node_modules/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
dist-ssr/
|
||||
build/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
*.local
|
||||
server/.env
|
||||
|
||||
# Database (SQLite)
|
||||
server/data/*.db
|
||||
server/data/*.db-shm
|
||||
server/data/*.db-wal
|
||||
!server/data/.gitkeep
|
||||
|
||||
# Generated audio files
|
||||
server/public/audio/*.mp3
|
||||
server/public/audio/*.flac
|
||||
server/public/audio/*.wav
|
||||
server/public/audio/*/
|
||||
!server/public/audio/.gitkeep
|
||||
|
||||
# Reference tracks (user uploads)
|
||||
server/public/audio/reference-tracks/*
|
||||
!server/public/audio/reference-tracks/.gitkeep
|
||||
|
||||
# User uploads
|
||||
server/public/uploads/
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Editor/IDE
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea/
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Vite cache
|
||||
.vite/
|
||||
|
||||
# Cache
|
||||
.cache/
|
||||
*.cache
|
||||
|
||||
# Test coverage
|
||||
coverage/
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
|
||||
# Demucs working files
|
||||
server/public/demucs-web/output/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
|
||||
# Misc
|
||||
ComfyUI/
|
||||
comfyui/
|
||||
|
||||
# External editors (wavacity is 184MB - clone separately if needed)
|
||||
wavacity-editor/
|
||||
@@ -0,0 +1,197 @@
|
||||
# ACE-Step UI
|
||||
|
||||
A local-first web UI for [ACE-Step 1.5](https://github.com/ace-step/ACE-Step) AI music generation.
|
||||
|
||||

|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- Generate music with text prompts and lyrics
|
||||
- Simple and Custom generation modes
|
||||
- Instrumental and vocal track support
|
||||
- Audio editor with waveform visualization
|
||||
- Stem extraction (vocals, drums, bass, other)
|
||||
- Video generator with Pexels backgrounds
|
||||
- Local SQLite database (no cloud required)
|
||||
- Playlist management
|
||||
- Beautiful gradient album covers (no internet needed)
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Node.js** 18+
|
||||
- **Python** 3.10+ (3.11 recommended)
|
||||
- **NVIDIA GPU** with 8GB+ VRAM (12GB+ recommended)
|
||||
- **FFmpeg** and **FFprobe** (for audio processing)
|
||||
- [uv](https://github.com/astral-sh/uv) package manager (recommended for Python)
|
||||
|
||||
## Installation
|
||||
|
||||
### 1. Install ACE-Step
|
||||
|
||||
```bash
|
||||
# Clone ACE-Step
|
||||
git clone https://github.com/ace-step/ACE-Step
|
||||
cd ACE-Step
|
||||
|
||||
# Create virtual environment and install
|
||||
uv venv
|
||||
uv pip install -e .
|
||||
|
||||
# Download the model (first run will download automatically, or manually):
|
||||
# The model will be downloaded to ~/.cache/huggingface/
|
||||
|
||||
cd ..
|
||||
```
|
||||
|
||||
### 2. Install ACE-Step UI
|
||||
|
||||
```bash
|
||||
# Clone this repository
|
||||
git clone https://github.com/your-org/ace-step-ui
|
||||
cd ace-step-ui
|
||||
|
||||
# Run setup script
|
||||
./setup.sh
|
||||
```
|
||||
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
# Install frontend dependencies
|
||||
npm install
|
||||
|
||||
# Install server dependencies
|
||||
cd server
|
||||
npm install
|
||||
cd ..
|
||||
|
||||
# Copy environment file
|
||||
cp server/.env.example server/.env
|
||||
```
|
||||
|
||||
### 3. Configure (Optional)
|
||||
|
||||
Edit `server/.env` to customize:
|
||||
|
||||
```env
|
||||
# Server port
|
||||
PORT=3001
|
||||
|
||||
# ACE-Step API URL (default: http://localhost:8001)
|
||||
ACESTEP_API_URL=http://localhost:8001
|
||||
|
||||
# Database location
|
||||
DATABASE_PATH=./data/acestep.db
|
||||
|
||||
# Optional: Pexels API key for video backgrounds
|
||||
# Get a free key at https://www.pexels.com/api/
|
||||
PEXELS_API_KEY=your_key_here
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Step 1: Start ACE-Step API Server
|
||||
|
||||
In a terminal, start the ACE-Step API server:
|
||||
|
||||
```bash
|
||||
cd /path/to/ACE-Step
|
||||
uv run acestep-api --port 8001
|
||||
```
|
||||
|
||||
Wait until you see "Application startup complete" before proceeding.
|
||||
|
||||
### Step 2: Start ACE-Step UI
|
||||
|
||||
In another terminal:
|
||||
|
||||
```bash
|
||||
cd ace-step-ui
|
||||
./start.sh
|
||||
```
|
||||
|
||||
Or manually in two terminals:
|
||||
|
||||
```bash
|
||||
# Terminal 1 - Backend
|
||||
cd server && npm run dev
|
||||
|
||||
# Terminal 2 - Frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Step 3: Open the UI
|
||||
|
||||
Open http://localhost:3000 in your browser.
|
||||
|
||||
On first launch, you'll be asked to set a username. This is stored locally.
|
||||
|
||||
## Generating Music
|
||||
|
||||
1. **Simple Mode**: Enter a description of the music you want
|
||||
2. **Custom Mode**: Fine-tune parameters like BPM, key, duration, and add lyrics
|
||||
3. Click "Create" to generate
|
||||
|
||||
Generated songs are saved locally and appear in your library.
|
||||
|
||||
## Additional Features
|
||||
|
||||
### Audio Editor
|
||||
Click the waveform icon on any song to open the built-in audio editor for trimming, fading, and effects.
|
||||
|
||||
### Stem Extraction
|
||||
Extract vocals, drums, bass, and other stems from any song using the Demucs-powered stem separator.
|
||||
|
||||
### Video Generator
|
||||
Create music videos with:
|
||||
- Custom backgrounds from Pexels (requires API key)
|
||||
- Gradient animations
|
||||
- Lyrics display
|
||||
- Album art overlay
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "ACE-Step API not reachable"
|
||||
Make sure the ACE-Step API server is running on port 8001:
|
||||
```bash
|
||||
cd /path/to/ACE-Step
|
||||
uv run acestep-api --port 8001
|
||||
```
|
||||
|
||||
### "CUDA out of memory"
|
||||
- Close other GPU-intensive applications
|
||||
- Try generating shorter clips (30-60 seconds)
|
||||
- Reduce batch size if available
|
||||
|
||||
### Songs show 0:00 duration
|
||||
This can happen if FFprobe is not installed. Install it:
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt install ffmpeg
|
||||
|
||||
# macOS
|
||||
brew install ffmpeg
|
||||
```
|
||||
New songs will automatically detect duration. For existing songs, they'll update when played.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Run in development mode with hot reload
|
||||
./start.sh
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
cd server && npm run build
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
## Credits
|
||||
|
||||
- [ACE-Step](https://github.com/ace-step/ACE-Step) - AI music generation model
|
||||
- [Demucs](https://github.com/facebookresearch/demucs) - Audio source separation
|
||||
- [Pexels](https://www.pexels.com) - Stock video backgrounds
|
||||
@@ -0,0 +1,24 @@
|
||||
# AudioMass
|
||||
Free full-featured web-based audio & waveform editing tool
|
||||
|
||||
|
||||
Live: [https://audiomass.co](https://audiomass.co)
|
||||
|
||||
---
|
||||
|
||||
## Getting it to Run!
|
||||
1. please checkout this repo (or download it as zip)
|
||||
2. navigate to it through your favorite CLI, then access the ```src``` dir
|
||||
3. Run ```go run audiomass-server.go``` - or if you do not have golang installed, you can use a simple python webserver by running ```python audiomass-server.py```
|
||||
4. Navigate to [http://localhost:5055/](http://localhost:5055/) and have fun!
|
||||
|
||||
...
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
If you want to build the all.build.js minified file for delivery/publishing this then you can use uglify and run as:
|
||||
```cat dist/wavesurfer.js dist/plugin/wavesurfer.regions.js oneup.js app.js keys.js contextmenu.js ui-fx.js ui.js modal.js state.js engine.js actions.js drag.js recorder.js welcome.js fx-pg-eq.js fx-auto.js local.js id3.js lzma.js | uglifyjs -c -m -o all.build.js```
|
||||
|
||||
Thanks!
|
||||
@@ -0,0 +1,321 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>AudioMass - About</title>
|
||||
<meta charset="utf-8"/>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
|
||||
<meta name="description" content="AudioMass is a free full-featured web-based audio & waveform editing tool"/>
|
||||
<meta property="og:image" content="https://audiomass.co/icon.jpg"/>
|
||||
<meta property="og:title" content="AudioMass">
|
||||
<meta property="og:url" content="https://audiomass.co/">
|
||||
<meta property="og:description" content="AudioMass is a free full-featured web-based audio & waveform editing tool">
|
||||
<meta name="keywords" content="AudioMass, WebAudio, WaveForm, audio editing, free audio editing, audio tool, waveform editor, sound editor, open source">
|
||||
<link href="/ico.ico" rel="shortcut icon">
|
||||
|
||||
<style>
|
||||
body{
|
||||
padding:0;margin:0;
|
||||
font-family: "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif;
|
||||
color:rgb(5, 10, 5);
|
||||
background:#fefefe
|
||||
}
|
||||
#w{
|
||||
width: 90%;
|
||||
max-width:960px;
|
||||
margin:0 auto
|
||||
}
|
||||
|
||||
#w > div{
|
||||
padding:20px 0 0 0
|
||||
}
|
||||
|
||||
p{
|
||||
line-height:21px;
|
||||
font-size:16px;
|
||||
margin-bottom:1.2em
|
||||
}
|
||||
|
||||
h1{
|
||||
font-size: 26px;
|
||||
margin-top:35px
|
||||
}
|
||||
|
||||
h5{
|
||||
font-size:1em;
|
||||
margin-bottom:0
|
||||
}
|
||||
|
||||
.top{
|
||||
max-width:100%;
|
||||
outline:0;
|
||||
display:inline-block
|
||||
}
|
||||
|
||||
.mid{
|
||||
max-width:504px;
|
||||
width: 90%;
|
||||
outline:0;
|
||||
margin-bottom:16px;
|
||||
display:inline-block
|
||||
}
|
||||
|
||||
.nomarg{
|
||||
margin-bottom:0
|
||||
}
|
||||
|
||||
pre {
|
||||
padding:12px 16px;
|
||||
display:inline-block;
|
||||
border-radius:6px;
|
||||
background:#e1f3e9;
|
||||
text-shadow:0 1px #fff;
|
||||
border-bottom:1px solid #aaa;
|
||||
max-width:90%;
|
||||
overflow:scroll
|
||||
}
|
||||
|
||||
ul {
|
||||
padding:0;
|
||||
list-style:none
|
||||
}
|
||||
|
||||
li {
|
||||
padding:0 0 3px 0;
|
||||
color:#333;
|
||||
font-size:0.92em
|
||||
}
|
||||
|
||||
video {
|
||||
max-width:660px;
|
||||
width:90%;
|
||||
}
|
||||
|
||||
::-moz-selection { background: #080808; color:#fff; text-shadow:none }
|
||||
::selection { background: #080808; color:#fff; text-shadow:none }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="w">
|
||||
|
||||
|
||||
<div>
|
||||
<h1>Introducing AudioMass (<a href="https://audiomass.co" target="_blank">https://audiomass.co</a>) an open-source web based audio and waveform editing tool.</h1>
|
||||
|
||||
|
||||
<img class="top" src="/about/audiomass_top.jpg" />
|
||||
|
||||
|
||||
<p>AudioMass lets you record, or use your existing audio tracks, and modify them by trimming, cutting, pasting or applying a plethora of effects, from compression and paragraphic equalizers to reverb, delay and distortion fx. AudioMass also supports more than 20 hotkeys combinations and a dynamic responsive interface to ensure ease of use and that your producivity remains high.
|
||||
it is written solely in plain old-school javascript, weights approximately 65kb and has no backend or framework dependencies.</p>
|
||||
|
||||
<img class="mid" src="/about/audiomass_support.jpg" />
|
||||
<p>It also has very good browser and device support.</p>
|
||||
|
||||
|
||||
<h5>:: Feature List ::</h5>
|
||||
|
||||
<ul>
|
||||
<li>Loading Audio, navigating the waveform, zoom and pan</li>
|
||||
<li>Visualization of frequency levels</li>
|
||||
<li>Peak and distortion signaling</li>
|
||||
<li>Cutting/Pasting/Trimming parts of the audio</li>
|
||||
<li>Inverting and Reversing Audio</li>
|
||||
<li>Exporting to mp3</li>
|
||||
<li>Modifying volume levels</li>
|
||||
<li>Fade In/Out</li>
|
||||
<li>Compressor</li>
|
||||
<li>Normalization</li>
|
||||
<li>Reverb</li>
|
||||
<li>Delay</li>
|
||||
<li>Distortion</li>
|
||||
<li>Pitch Shift</li>
|
||||
<li>Keeps track of states so you can undo mistakes</li>
|
||||
<li>Offline support!</li>
|
||||
</ul>
|
||||
|
||||
<p>And all this, in only 83kb of JS!</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<h3>Getting Started</h3>
|
||||
|
||||
|
||||
<p>To get started, drag and drop an audio file, or try the included sample.<br />
|
||||
Once the file is loaded and you can view the waveform, zoom in, pan around, or select a region.</p>
|
||||
|
||||
<img class="mid" src="/about/audiomass_2.jpg" />
|
||||
|
||||
|
||||
<h3>Recording Audio</h3>
|
||||
|
||||
<p>To record audio, simply press the Recording button, or the <i>[R]</i> key.</p>
|
||||
|
||||
<img class="mid" src="/about/audiomass_3.jpg" />
|
||||
|
||||
|
||||
<h3>Exporting to mp3</h3>
|
||||
|
||||
<p>In order to export back to mp3, click on 'File', then 'Export to mp3', and follow the modal's instructions.</p>
|
||||
|
||||
<img class="mid" src="/about/audiomass_4.jpg" />
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<hr />
|
||||
|
||||
|
||||
<div>
|
||||
<h3>The story behind AudioMass. And a short rant on web interfaces.</h3>
|
||||
|
||||
<p>I wrote AudioMass back in June 2018 and it stayed dormant on my hard disk until I decided to share it with the world today (July 13th, 19 -- but you might be seeing this in 2020. Hi!!).
|
||||
It started as a personal small tool for quick visualization of waveforms. Later I added the ability to cut/copy/paste as well as fade in and out. And soon after good 'ol feature creep and perfectionism took over! Soon after, it turned to a challenge to see how close to a full-featured waveform editor it can get, whilst maintaining acceptable performance and small filesize.</p>
|
||||
|
||||
<p>In general I am a big fan of the interfaces of DAWs <i>(Digital Audio Workstations)</i>, they are extremely, complex, intricate, versatile, and they manage to remain visually pleasant even through their infinite options and knobs. Many times I feel the web has taken a very wrong turn, as amazing interfaces such as...
|
||||
|
||||
<p class="nomarg">Sonar</p>
|
||||
<img class="mid" src="/about/sonar.jpg" />
|
||||
|
||||
<br />
|
||||
<p class="nomarg">Fruity loops</p>
|
||||
<img class="mid" src="/about/fruity.png" />
|
||||
|
||||
<br />
|
||||
|
||||
<p>Existed for more than 10-15 years now, while we are struggling with animating some rectangles for 60fps... So for AudioMass I wanted to try and make a fast and performant interface. Drawing inspiration from the examples I mentioned earlier rather than the tradiional web development practices. This is my unconvincing but truthfull excuse as to why the code is ugly; it is focused on being fast and getting the job done, with little regard on structure.</p>
|
||||
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div>
|
||||
|
||||
<h3>Building the interface</h3>
|
||||
|
||||
<p>Let's say we have a <i>"PLAY"</i> button and when we press it the track begins to play. I suppose we would want the button's color and state to reflect that the track is now playing. So naively we would do something like;</p>
|
||||
|
||||
|
||||
<pre><code>btn.onclick = function () {
|
||||
this.classList.add ('active');
|
||||
};</code></pre>
|
||||
|
||||
<p>But what happens if we have a hotkey that triggers the same action? Let's say we press <i>[SPACEBAR]</i> and the track begins to play. Do we modify that button's class in the spacebar's handler?</p>
|
||||
|
||||
<pre><code>document.onkeypress = function ( e ) {
|
||||
if ( e.keyCode === 32) {
|
||||
e.preventDefault ();
|
||||
|
||||
document.querySelector ('.playbtn').classList.add ('active');
|
||||
}
|
||||
};</code></pre>
|
||||
|
||||
<p>And what happens, if there are 2 buttons, or one gets dynamically removed? Do we do selectAll and iterate? Hmmm...<br />
|
||||
And if the track is playing and we hit <i>[SPACEBAR]</i> or the play button again, we need to stop playing. What do we do then? You can see how this becomes messy very quickly as everything gets very tighlty coupled together in a big dependency ball.</p>
|
||||
|
||||
|
||||
|
||||
<p>Introducing the observer pattern. Actions are represented by events. So the above logic and be expressed as;</p>
|
||||
|
||||
|
||||
<pre><code>btn.onclick = function () {
|
||||
FireEvent ('RequestTogglePlay');
|
||||
};
|
||||
|
||||
On ('WillPlay', function () {
|
||||
btn.classList.add ('active');
|
||||
});
|
||||
|
||||
On ('WillStop', function () {
|
||||
btn.classList.remove ('active');
|
||||
});
|
||||
|
||||
|
||||
|
||||
document.onkeypress = function ( e ) {
|
||||
if ( e.keyCode === 32) {
|
||||
e.preventDefault ();
|
||||
|
||||
FireEvent ('RequestTogglePlay');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
On ('RequestTogglePlay', function () {
|
||||
if (track.is_playing) {
|
||||
FireEvent ('WillStop');
|
||||
track.stop ();
|
||||
}
|
||||
else {
|
||||
FireEvent ('WillPlay');
|
||||
track.play ();
|
||||
}
|
||||
});
|
||||
|
||||
track.onPlayStart = function () {
|
||||
FireEvent ('DidPlay');
|
||||
};
|
||||
track.onPlayStop = function () {
|
||||
FireEvent ('DidStop');
|
||||
};
|
||||
</code></pre>
|
||||
|
||||
<p>Now this is completely decoupled and dependency free! The button will set its state according to the events it receives, and both the button and the spacebar key rely on the same mechanisms.
|
||||
You may notice the vocabulary we are using. <i>"Request"</i>, <i>"Will"</i> and <i>"Did"</i>. These are arbitrarilly chosen to impose some extra structure.<br /> <i>"Request"</i> denotes intent to perform an action, it is not guaranteed that the action will execute as there might be conditions preventing it (eg unitialized or still loading objects). <i>"Will"</i> means that the conditions passed and we are attempting to perform the action. And <i>"Did"</i> means that the action just got performed.<br />
|
||||
It might be a bit too verbose, but it worked very well for structuring AudioMass's interface.</p>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div>
|
||||
<h3>Dockable UI</h3>
|
||||
|
||||
<p>One thing I love about DAW interfaces, is that every window can be pulled out of the main host. I fondly remember having 3 screens full of VST plugins. So can we do the same in the browser?</p>
|
||||
|
||||
|
||||
<video src="/about/dock_ui.mp4" autoplay muted playsinline controls loop></video>
|
||||
|
||||
|
||||
<p>Yes! And it is using some of the oldest tricks in the book. Essentially we create a pop-up window with <i>window.open</i> and just pass buffers to its <i>documentWindow</i> object. Surprisingly it is quite performant on all browsers except IE Edge. I believe they are serializing in ascii or base64 every packet or something. Also Chrome has an interesting bug where you can't pass more than 512 byte buffers.</p>
|
||||
|
||||
<p>So for the undocked window, we call <i>window.open</i>. But how do we make it work when it is docked? It would be quite cumbersome to write the same functionality twice, once as a standalone page, and once as an in-page script. Luckily we can avoid that completely, and re-use the same page by using iframes.</p>
|
||||
|
||||
<p>The only difference is that the undocked version uses <i>window.opener</i> to refference its parent, whereas the iframe uses <i>window.parent</i>.</p>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<hr />
|
||||
|
||||
<div>
|
||||
<h3>Future work and performance considerations</h3>
|
||||
|
||||
<p>The next big feature that is planned, is multitrack support. The ability to mix tracks and different sounds is currently lacking and its usefulness as it stands is limited.</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<p>There is also a lot of room for improvement in almost all aspects.</p>
|
||||
|
||||
<p>First of all we can further reduce the filesize by around 20kb by removing the library we are using for rendering the waveform. We use only a fraction of its functionality so there is no reason to include it all.</p>
|
||||
|
||||
<p>We can also optimize a lot the rendering of the waveform. I heavily modified the library used to compute and draw only the visible range. However it is still clearing and re-drawing all of the canvas at each frame. We can take advantage of 2d Context's translate calls, and shift the canvas around instead of redrawing all of the pixels.</p>
|
||||
|
||||
<p>We can also move some operations to a background thread, such as the filters processing so that the UI does not freeze when applying a long chain of effects.</p>
|
||||
|
||||
<br />
|
||||
|
||||
|
||||
<p>However, the biggest issue I encountered, is the Web Audio API itself. Every operation results in iterating over multiple long arrays per frame. Eventually the garbage collector fires and crackling is introduced. Only way to go around this is to use a small fftSize, but then the frequency range we have to work with is very narrow. Perhaps a pure WASM implementation would outperform trying to modify audio signals with JS. Only one way to find out I guess :) </p>
|
||||
<p>Additionally, <i>decodeAudioData</i> provides no progress callback, and no way of cancelling it. So if you attempt to load a huge audio file, you will waste resources until it gets processed. There is no way around this currently and it can get annoying if you push a big file by mistake.</p>
|
||||
|
||||
</div>
|
||||
|
||||
<br /><br /><br />
|
||||
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 757 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 135 KiB |
@@ -0,0 +1,107 @@
|
||||
(function (w, d) {
|
||||
'use strict';
|
||||
|
||||
var _v = '0.9',
|
||||
_id = -1;
|
||||
|
||||
function PKAE() {
|
||||
var q = this; // keeping track of current context
|
||||
|
||||
q.el = null; // reference of main html element
|
||||
q.id = ++_id; // auto incremental id
|
||||
q._deps = {}; // dependencies
|
||||
|
||||
w.PKAudioList[q.id] = q;
|
||||
|
||||
var events = {};
|
||||
|
||||
q.fireEvent = function (eventName, value, value2) {
|
||||
var group = events[eventName];
|
||||
if (!group) return (false);
|
||||
|
||||
var l = group.length;
|
||||
while (l-- > 0) {
|
||||
group[l] && group[l](value, value2);
|
||||
}
|
||||
};
|
||||
|
||||
q.listenFor = function (eventName, callback) {
|
||||
if (!events[eventName])
|
||||
events[eventName] = [callback];
|
||||
else
|
||||
events[eventName].unshift(callback);
|
||||
};
|
||||
|
||||
q.stopListeningFor = function (eventName, callback) {
|
||||
var group = events[eventName];
|
||||
if (!group) return (false);
|
||||
|
||||
var l = group.length;
|
||||
while (l-- > 0) {
|
||||
if (group[l] && group[l] === callback) {
|
||||
group[l] = null; break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
q.stopListeningForName = function (eventName) {
|
||||
var group = events[eventName];
|
||||
if (!group) return (false);
|
||||
events[eventName] = null;
|
||||
};
|
||||
|
||||
q.init = function (el_id) {
|
||||
var el = d.getElementById(el_id);
|
||||
if (!el) {
|
||||
console.log('invalid element');
|
||||
return;
|
||||
}
|
||||
q.el = el;
|
||||
|
||||
// init libraries
|
||||
q.ui = new q._deps.ui(q); q._deps.uifx(q);
|
||||
q.engine = new q._deps.engine(q);
|
||||
q.state = new q._deps.state(4, q);
|
||||
q.rec = new q._deps.rec(q);
|
||||
q.fls = new q._deps.fls(q);
|
||||
|
||||
if (w.location.href.split('local=')[1]) {
|
||||
var sess = w.location.href.split('local=')[1];
|
||||
|
||||
q.fls.Init(function () {
|
||||
q.fls.GetSession(sess, function (e) {
|
||||
if (e && e.id === sess) {
|
||||
q.engine.LoadDB(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Check for audioUrl parameter to load audio from URL
|
||||
if (w.location.href.split('audioUrl=')[1]) {
|
||||
var audioUrl = decodeURIComponent(w.location.href.split('audioUrl=')[1].split('&')[0]);
|
||||
|
||||
setTimeout(function () {
|
||||
if (audioUrl) {
|
||||
q.engine.LoadURL(audioUrl);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
return (q);
|
||||
};
|
||||
|
||||
// check if we are mobile and hide tooltips on hover
|
||||
q.isMobile = (/iphone|ipod|ipad|android/).test
|
||||
(navigator.userAgent.toLowerCase());
|
||||
};
|
||||
|
||||
!w.PKAudioList && (w.PKAudioList = []);
|
||||
|
||||
// ideally we do not want a global singleto refferencing our audio tool
|
||||
// but since this is a limited demo we can safely do it.
|
||||
w.PKAudioEditor = new PKAE();
|
||||
|
||||
PKAudioList.push(w.PKAudioEditor); // keeping track in the audiolist array of our instance
|
||||
|
||||
})(window, document);
|
||||
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
_ "net/url"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
changeHeaderThenServe := func(h http.Handler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Access-Control-Allow-Origin", "*")
|
||||
|
||||
if strings.Contains(r.URL.Path, ".wasm") {
|
||||
w.Header().Add("Content-Type", "application/wasm")
|
||||
}
|
||||
|
||||
if strings.Contains(r.URL.Path, ".js") {
|
||||
var epoch = time.Unix(0, 0).Format(time.RFC1123)
|
||||
var noCacheHeaders = map[string]string{
|
||||
"Expires": epoch,
|
||||
"Cache-Control": "no-cache, private, max-age=0",
|
||||
"Pragma": "no-cache",
|
||||
"X-Accel-Expires": "0",
|
||||
}
|
||||
|
||||
for k, v := range noCacheHeaders {
|
||||
w.Header().Set(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Req:", r.Host, r.URL.Path)
|
||||
h.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
cmd := exec.Command("open", "-a", "Google Chrome", "http://localhost:5055/")
|
||||
cmd.Output()
|
||||
}()
|
||||
|
||||
fmt.Printf("\nListening on http://localhost:5055 \n\n")
|
||||
http.Handle("/", changeHeaderThenServe(http.FileServer(http.Dir("."))))
|
||||
panic(http.ListenAndServe(":5055", nil))
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import SimpleHTTPServer
|
||||
import SocketServer
|
||||
PORT = 5055
|
||||
Handler = SimpleHTTPServer.SimpleHTTPRequestHandler
|
||||
httpd = SocketServer.TCPServer(("", PORT), Handler)
|
||||
print "serving at port", PORT
|
||||
httpd.serve_forever()
|
||||
@@ -0,0 +1,12 @@
|
||||
// Add this to app.js after line 78 (after the local= parameter check)
|
||||
|
||||
// Check for audioUrl parameter to load audio from URL
|
||||
if (w.location.href.split('audioUrl=')[1]) {
|
||||
var audioUrl = decodeURIComponent(w.location.href.split('audioUrl=')[1].split('&')[0]);
|
||||
|
||||
setTimeout(function () {
|
||||
if (audioUrl) {
|
||||
q.engine.LoadURL(audioUrl);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
CACHE MANIFEST
|
||||
# v1 - July 26, 2018
|
||||
# v2 - May 25, 2020
|
||||
|
||||
/index-cache.html
|
||||
/main.css
|
||||
/dist/wavesurfer.js
|
||||
/dist/plugin/wavesurfer.regions.js
|
||||
/oneup.js
|
||||
/app.js
|
||||
/keys.js
|
||||
/contextmenu.js
|
||||
/ui-fx.js
|
||||
/ui.js
|
||||
/modal.js
|
||||
/state.js
|
||||
/engine.js
|
||||
/actions.js
|
||||
/drag.js
|
||||
/recorder.js
|
||||
/welcome.js
|
||||
/fx-pg-eq.js
|
||||
/fx-auto.js
|
||||
/local.js
|
||||
/id3.js
|
||||
/lzma.js
|
||||
/lame.js
|
||||
/fonts/icomoon.ttf
|
||||
/fonts/icomoon.woff
|
||||
/test.mp3
|
||||
|
||||
|
||||
NETWORK:
|
||||
/index-cache.html
|
||||
|
||||
# Fallback content
|
||||
FALLBACK:
|
||||
. /index-cache.html
|
||||
@@ -0,0 +1,205 @@
|
||||
(function ( win, doc, PKAE ) {
|
||||
'use strict';
|
||||
|
||||
var activeMenu = [],
|
||||
namespace = win,
|
||||
contextStorage = {},
|
||||
_id = 0;
|
||||
|
||||
var closeEvent = [ 'mousedown', 'touchup' ];
|
||||
|
||||
/**
|
||||
* Goes through every single context instance and terminates
|
||||
* it.
|
||||
**/
|
||||
var closeContext = function ( e, force ) {
|
||||
if (!e) return ;
|
||||
if (activeMenu.length === 0) return ;
|
||||
|
||||
var el = e.target || e.srcElement;
|
||||
|
||||
if (!el || el.className.indexOf('_action') === -1 || force)
|
||||
{
|
||||
var l = activeMenu.length;
|
||||
while (l--) terminate (activeMenu[ l ]);
|
||||
activeMenu = [];
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Go through every children element of the context el,
|
||||
* and remove all listeners and added attributes, then remove it
|
||||
* from the dom also
|
||||
**/
|
||||
terminate = function( e ) {
|
||||
var children = e.currentMenu.getElementsByTagName('*'),
|
||||
len = children.length;
|
||||
|
||||
while( len-- )
|
||||
children[ len ].parentNode.removeChild( children[ len ] );
|
||||
|
||||
e.currentMenu.removeEventListener( closeEvent, stopPropagation );
|
||||
doc.body.removeChild( e.currentMenu );
|
||||
e.currentMenu = null;
|
||||
return false;
|
||||
},
|
||||
/** stop propagation func, so that we don't have to use anonymous funcs **/
|
||||
stopPropagation = function(e){ e.stopPropagation(); },
|
||||
openContext = function( e, x, y ) {
|
||||
|
||||
closeContext(null);
|
||||
activeMenu.push( e );
|
||||
|
||||
//go through all the options and make the div
|
||||
var div = doc.createElement('div'),
|
||||
a,
|
||||
marginOffset = 4,
|
||||
opts = e.options,
|
||||
leftOffset = x - marginOffset,
|
||||
topOffset = y - marginOffset,
|
||||
width = 0, height = 0;
|
||||
|
||||
div.className = "pk_contextMenu " + e.menuClass;
|
||||
div.id = e.token;
|
||||
|
||||
for( var i = 0, len = opts.length; i < len; ++i ) {
|
||||
if( opts[ i ].isHTML )
|
||||
{
|
||||
a = doc.createElement('div');
|
||||
a.innerHTML = opts[ i ].isHTML;
|
||||
div.appendChild( a );
|
||||
}
|
||||
else {
|
||||
a = doc.createElement('a');
|
||||
a.className = 'pk_ctx_action';
|
||||
a.cnt = 1;
|
||||
a.innerHTML = opts[ i ].name;
|
||||
a.callback = opts[ i ].callback;
|
||||
|
||||
a.addEventListener( 'click', a.callback, false );
|
||||
|
||||
div.appendChild( a );
|
||||
}
|
||||
}
|
||||
|
||||
e.currentMenu = div;
|
||||
div.addEventListener( closeEvent, stopPropagation, false );
|
||||
|
||||
doc.body.appendChild( div );
|
||||
|
||||
width = div.offsetWidth;
|
||||
height = div.offsetHeight;
|
||||
|
||||
if( win.innerWidth < ( leftOffset + width ) && win.innerHeight < ( topOffset + height ) )
|
||||
div.style.cssText = "top:" + ( topOffset - height ) + "px;left:" + ( leftOffset - width ) + "px;";
|
||||
else if( win.innerWidth < ( leftOffset + width ) )
|
||||
div.style.cssText = "top:" + ( topOffset ) + "px;left:" + ( leftOffset - width ) + "px;";
|
||||
else if( win.innerHeight < ( topOffset + height ) )
|
||||
div.style.cssText = "top:" + ( topOffset - height ) + "px;left:" + ( leftOffset ) + "px;";
|
||||
else
|
||||
div.style.cssText = "top:" + ( topOffset ) + "px;left:" + ( leftOffset ) + "px;";
|
||||
|
||||
if (e.onOpen) {
|
||||
e.onOpen ( e, div );
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
openMenu = function( e )
|
||||
{
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
else {
|
||||
e = {pageX:0, pageY:0};
|
||||
}
|
||||
|
||||
// ----
|
||||
var instance = getInstance ( this );
|
||||
var pageX = e.pageX || e.clientX + doc.documentElement.scrollLeft;
|
||||
var pageY = e.pageY || e.clientY + doc.documentElement.scrollTop;
|
||||
|
||||
if (!instance) return false;
|
||||
|
||||
instance.curr_target = e.target || e.srcElement;
|
||||
|
||||
openContext ( instance, pageX, pageY );
|
||||
},
|
||||
getInstance = function( elem ) {
|
||||
return contextStorage[ elem.getAttribute( 'data-token' ) ];
|
||||
};
|
||||
|
||||
/**
|
||||
* Context Menu Constructor
|
||||
**/
|
||||
var contextMenu = namespace.contextMenu = function( elem, options ) {
|
||||
if (!(this instanceof contextMenu)) return new contextMenu( elem, options );
|
||||
if (!options) options = {};
|
||||
|
||||
var open_events = ['contextmenu', 'longpress'];
|
||||
|
||||
this.elem = elem;
|
||||
this.options = [];
|
||||
this.menuClass = options.className || 'pk_open';
|
||||
this.curr_target = null;
|
||||
|
||||
// modified context menu to open only when double click + no movement
|
||||
// if (elem) elem.addEventListener( 'contextmenu', openMenu, false );
|
||||
if (elem) elem.addEventListener( 'pk_ctxmn', openMenu, false );
|
||||
|
||||
this.token = ++_id;
|
||||
if (elem) elem.setAttribute( 'data-token', this.token );
|
||||
|
||||
contextStorage[ this.token ] = this;
|
||||
};
|
||||
/**
|
||||
* Wrapper to the private openMenu function
|
||||
**/
|
||||
contextMenu.prototype.open = function( e ) {
|
||||
openMenu.call( this.elem, e );
|
||||
};
|
||||
contextMenu.prototype.close = function( e ) {
|
||||
closeContext();
|
||||
};
|
||||
contextMenu.prototype.openWithToken = function( token, x, y ) {
|
||||
openContext( contextStorage[ token ], x||0, y||0 );
|
||||
};
|
||||
/**
|
||||
* Closes context and removes it fully
|
||||
**/
|
||||
contextMenu.prototype.destroy = function() {
|
||||
// this.elem.removeEventListener( 'contextmenu', openMenu );
|
||||
this.elem.removeEventListener( 'pk_ctxmn', openMenu );
|
||||
|
||||
closeContext();
|
||||
contextStorage[ this.token ] = null;
|
||||
|
||||
return false;
|
||||
},
|
||||
/**
|
||||
* Adds option
|
||||
* @param string name of the option
|
||||
* @param function to run when its chosen
|
||||
* @param if this is set, then append the HTML instead of its name in its position
|
||||
* @param initialization code to run when the object is appended to the dom
|
||||
**/
|
||||
contextMenu.prototype.addOption = function( name, callback, isHTML ) {
|
||||
var q = this;
|
||||
this.options.push({ "name" : name, "callback" : function( e ) {
|
||||
|
||||
callback && callback( q, q._open );
|
||||
closeContext( q, true );
|
||||
},
|
||||
"isHTML" : isHTML
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// todo touch controls too? ####
|
||||
doc.addEventListener( closeEvent[0], closeContext, false );
|
||||
doc.addEventListener( 'killCTX', closeContext, false );
|
||||
|
||||
|
||||
PKAE._deps.ContextMenu = contextMenu;
|
||||
|
||||
})( window, document, PKAudioEditor );
|
||||
@@ -0,0 +1,178 @@
|
||||
(function( parent ) {
|
||||
'use strict';
|
||||
|
||||
/** parent object, set in the end of the selfcalling function **/
|
||||
var parent = parent || window,
|
||||
/** instance of File Reader **/
|
||||
reader,
|
||||
readFile,
|
||||
/**
|
||||
* Removes class from element
|
||||
* @param htmlObject target element
|
||||
* @param string "class to be removed"
|
||||
**/
|
||||
removeClass = function( el, value ) {
|
||||
if ( !el.className ) return false;
|
||||
var classes = el.className.split(' '),
|
||||
ret = [];
|
||||
for( var i = 0, l = classes.length; i < l; ++i )
|
||||
if( classes[i] != value )
|
||||
ret.push( classes[ i ] );
|
||||
el.className = ret.join(' ');
|
||||
};
|
||||
|
||||
if( !window.FileReader || !document.addEventListener ) {
|
||||
throw( "File API not supported" );
|
||||
readFile = function(){ throw( "File API not supported" ); };
|
||||
}
|
||||
else {
|
||||
reader = new FileReader();
|
||||
readFile = function ( file, callback, method ) {
|
||||
/** Error handler (throws error at the console) **/
|
||||
reader.onerror = function( e ) {
|
||||
var message,
|
||||
lut = [ "File not found.", "File coulnot be opened",
|
||||
"File couldnot be uploaded", "Couldnot read File", "File too large" ];
|
||||
// http://www.w3.org/TR/FileAPI/#ErrorDescriptions
|
||||
throw( lut[ ( e.target.error.code - 1 ) ] );
|
||||
},
|
||||
/** Success, calling the callback **/
|
||||
reader.onloadend = function( e ) {
|
||||
callback && callback( e.target.result, file.name );
|
||||
reader.onloadend = null;
|
||||
};
|
||||
|
||||
// the method is specified in the beginning of the file
|
||||
reader[ method ]( file );
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag n Drop Files module
|
||||
* @param HTMLElement, could be the Body
|
||||
* @param DOMElement/String, if a string is specified then a div will be built and appended to the body
|
||||
* with that String as its id. If a dom element is passed, that will be used instead. This object
|
||||
* acts as an overlay and the file should be droped to this object. If this object is null, then the first argument
|
||||
* will be used as the overlay.
|
||||
* @param Function, will be called with the file data, and the filename as its arguments
|
||||
* @param String, possible values "text, binarytext, arrabuffer" decides how the file will be read
|
||||
* if let null, defaults to text
|
||||
* @param String, class name to be added to the overlay (default is '__fadingIn')
|
||||
**/
|
||||
parent.dragNDrop = function( body, overlay_id, callback, method, _clss ) {
|
||||
var win = window;
|
||||
// check to see if we are using a mobile device - no need for dragNdrop in devices
|
||||
// that do not support it somehow yet
|
||||
if( ( 'ontouchstart' in window ) )
|
||||
return "mobile";
|
||||
|
||||
/** JS Object, used to define the file-reading method **/
|
||||
var method_lut = {
|
||||
'text' : 'readAsText',
|
||||
'binary' : 'readAsBinaryText',
|
||||
'arrayBuffer': 'readAsArrayBuffer'
|
||||
},
|
||||
/** class added/removed from overlay object **/
|
||||
clss = _clss ? _clss : "__fadingIn",
|
||||
method = method ? method_lut[ method ] : 'readAsText',
|
||||
/** how many events cast (dragenter/dragleave) **/
|
||||
entered = 0,
|
||||
/**
|
||||
* DOMElement sink for the drag events
|
||||
* if left unspecified then the body inherits the role
|
||||
**/
|
||||
overlay = !!overlay_id ? overlay_id : body,
|
||||
/**
|
||||
* (void) if the overlay_id specified is a string, then a div with that id is built
|
||||
* and appended to the body. Else the default is used
|
||||
**/
|
||||
_overlayBuilder = function() {
|
||||
if( typeof overlay_id === "string" )
|
||||
{
|
||||
var tmp = document.createElement( 'div' );
|
||||
overlay = document.createElement( 'div' );
|
||||
overlay.id = overlay_id;
|
||||
|
||||
tmp.innerHTML = "Drag n drop Files!";
|
||||
overlay.appendChild( tmp );
|
||||
|
||||
body.appendChild( overlay );
|
||||
tmp = null;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* JS Object
|
||||
* The events Object contains various functions that control
|
||||
* the behavior of the events fired
|
||||
**/
|
||||
events = {
|
||||
/**
|
||||
* (void) Prevents default action and bubbling up
|
||||
**/
|
||||
silencer : function( e ) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation()
|
||||
},
|
||||
|
||||
/**
|
||||
* Shows message to drop file
|
||||
**/
|
||||
onDragEnter : function( e ) {
|
||||
// overlay.className += " " + clss;
|
||||
++entered;
|
||||
|
||||
setTimeout(function() {
|
||||
if( entered > 1 )
|
||||
entered = 1;
|
||||
}, 10 )
|
||||
},
|
||||
/**
|
||||
* Hides the overlay... twist included!
|
||||
**/
|
||||
onDragLeave : function( e ) {
|
||||
--entered;
|
||||
|
||||
if( entered <= 0 )
|
||||
{
|
||||
removeClass( overlay, clss );
|
||||
entered = 0;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Files dropped
|
||||
**/
|
||||
onDrop : function( e ) {
|
||||
// prevent the event from bubbling/firing default
|
||||
events.silencer( e );
|
||||
|
||||
// Hide the overlay
|
||||
removeClass( overlay, clss );
|
||||
entered = 0;
|
||||
|
||||
/** dropped files. **/
|
||||
var files = e.dataTransfer.files,
|
||||
len;
|
||||
|
||||
// If anything is wrong with the dropped files, exit.
|
||||
if( !files || !files.length )
|
||||
return false;
|
||||
|
||||
len = files.length;
|
||||
while( len-- )
|
||||
// iterate files array and load them
|
||||
readFile( files[ len ], callback, method );
|
||||
}
|
||||
};
|
||||
|
||||
(function init() {
|
||||
//_overlayBuilder();
|
||||
// events initialization
|
||||
body.parentNode.addEventListener( "dragenter", events.onDragEnter, false );
|
||||
body.addEventListener( "dragleave", events.onDragLeave, false );
|
||||
body.addEventListener( "dragover", events.silencer, false);
|
||||
body.addEventListener( "drop", events.onDrop, false);
|
||||
return false;
|
||||
})( body );
|
||||
};
|
||||
})( window );
|
||||
@@ -0,0 +1,220 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<script>window.update = function(){};</script>
|
||||
<title>AudioMass - Frequency Analysis</title>
|
||||
<style>
|
||||
html,body{height:100%}
|
||||
body{padding:0;margin:0;background:#111;position:relative;z-index:2;overflow:hidden;}
|
||||
div{position:absolute;top:50%;left:0;right:0;z-index:1;margin-top:-18px;text-align:center;user-select:none;
|
||||
font-size:28px;color:#191919;pointer-events:none;font-family:Arial}
|
||||
#e, #d, #f{
|
||||
cursor:pointer;
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
right: 7px;
|
||||
display: block;
|
||||
height: 20px;
|
||||
z-index: 99999;
|
||||
background: #eaeaea;
|
||||
left:auto;
|
||||
border-radius:4px;
|
||||
font-family: sans-serif;
|
||||
font-size: 9px;
|
||||
width: 82px;
|
||||
line-height: 20px;
|
||||
padding: 0 6px;
|
||||
text-align: center;
|
||||
opacity:0.9;
|
||||
user-select:none;
|
||||
}
|
||||
|
||||
.b #e, .b #d{
|
||||
top:12px;
|
||||
}
|
||||
|
||||
.b #e, .b #f{
|
||||
display:block;
|
||||
}
|
||||
|
||||
#e{
|
||||
right: 106px;
|
||||
width: 10px;
|
||||
line-height: 20px;
|
||||
padding: 0px 6px;
|
||||
text-align: center;
|
||||
opacity: 0.9;
|
||||
display:none;
|
||||
}
|
||||
|
||||
#f{
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
line-height: 0;
|
||||
border-radius: 0;
|
||||
height: 4px;
|
||||
background: #333;
|
||||
display:none;
|
||||
user-select:none;
|
||||
-moz-user-select:none;
|
||||
}
|
||||
|
||||
.c #f, #f:hover{
|
||||
background:#3C3C3C;
|
||||
}
|
||||
|
||||
</style>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="fr" width="600" height="188" style="width:100%;height:100%;display:block"></canvas>
|
||||
<div>FREQUENCY ANALYSER</div>
|
||||
|
||||
<a id="d" onclick="dock()">DOCK INTERFACE</a>
|
||||
<a id="e" onclick="remove()">X</a>
|
||||
<a id="f" onmousedown="drag(event)"></a>
|
||||
|
||||
<script>
|
||||
var d = document;
|
||||
var w = window;
|
||||
var iframe = location.href.indexOf('?iframe') === -1 ? 0 : 1;
|
||||
var canvas = d.getElementById('fr');
|
||||
var ctx = canvas.getContext('2d', {alpha:true, antialias:false});
|
||||
|
||||
w.remove = function (){};
|
||||
if (iframe) {
|
||||
d.body.className = 'b';
|
||||
w.remove = function () {
|
||||
w.parent.PKAudioEditor.ui.Dock ('RequestShowFreqAn', 'eq', [1, 1]);
|
||||
};
|
||||
};
|
||||
|
||||
w.drag = function ( e ) {
|
||||
e.preventDefault ();
|
||||
e.stopPropagation ();
|
||||
|
||||
w.parent.PKAudioEditor.ui.Dock ('RequestDragI', 'eq');
|
||||
};
|
||||
|
||||
w.dock = function () {
|
||||
if (!iframe)
|
||||
{
|
||||
if (!w.opener || !w.opener.PKAudioEditor) {
|
||||
return ;
|
||||
}
|
||||
|
||||
w.opener.PKAudioEditor.ui.Dock ('RequestShowFreqAn', 'eq', [1, 1]);
|
||||
w.close && w.close ();
|
||||
}
|
||||
else
|
||||
{
|
||||
var frm = w.parent.document.getElementById ('pk_fr' + 'eq');
|
||||
var t = 1;
|
||||
|
||||
if (frm && frm.getBoundingClientRect) {
|
||||
var rect = frm.getBoundingClientRect();
|
||||
|
||||
t = [(w.parent.screenLeft + rect.left + 100)||0, (w.parent.screenTop + rect.top + 25)||0];
|
||||
}
|
||||
|
||||
w.parent.PKAudioEditor.ui.Dock ('RequestShowFreqAn', 'eq', [t, 0]);
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(function () {
|
||||
if (!iframe)
|
||||
{
|
||||
if (!w.opener || !w.opener.PKAudioEditor) return ;
|
||||
}
|
||||
|
||||
var WIDTH = w.innerWidth, HEIGHT = w.innerHeight;
|
||||
|
||||
if (canvas.width != WIDTH)
|
||||
{
|
||||
canvas.width = WIDTH;
|
||||
canvas.height = HEIGHT;
|
||||
}
|
||||
|
||||
var bufferLength = 240;
|
||||
var value_changed = false;
|
||||
ctx.clearRect (0, 0, WIDTH, HEIGHT);
|
||||
|
||||
function draw( similarity ) {
|
||||
|
||||
var WIDTH = window.innerWidth, HEIGHT = window.innerHeight;
|
||||
|
||||
if (canvas.width != WIDTH || canvas.height != HEIGHT)
|
||||
{
|
||||
canvas.width = WIDTH;
|
||||
canvas.height = HEIGHT;
|
||||
}
|
||||
|
||||
ctx.fillStyle = 'rgb(0, 0, 0)';
|
||||
ctx.fillRect(0, 0, WIDTH, HEIGHT);
|
||||
|
||||
var barWidth = (WIDTH / bufferLength);
|
||||
var barHeight;
|
||||
var x = 0;
|
||||
|
||||
for(var i = 0; i < bufferLength; i += 1) {
|
||||
barHeight = similarity[i * 2]; // ? similarity[i] : 0;
|
||||
|
||||
// bar height normalize with 255
|
||||
var newheight = ((barHeight / 256) * HEIGHT) >> 0;
|
||||
|
||||
ctx.fillStyle = 'rgb(' + (barHeight + 100) + ',50,50)';
|
||||
ctx.fillRect (x,HEIGHT - newheight, barWidth, newheight);
|
||||
x += barWidth;// + 1;
|
||||
}
|
||||
|
||||
value_changed = false;
|
||||
};
|
||||
w.draw = draw;
|
||||
|
||||
w.onunload = function () {
|
||||
w.destroy && w.destroy ( iframe );
|
||||
w.destroy = null;
|
||||
};
|
||||
|
||||
w.update = function (freq_arr) {
|
||||
if (!freq_arr)
|
||||
ctx.clearRect (0, 0, WIDTH, HEIGHT);
|
||||
else {
|
||||
if (value_changed) return ;
|
||||
value_changed = true;
|
||||
|
||||
window.requestAnimationFrame(function () {
|
||||
draw (freq_arr);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
var last_press = 0;
|
||||
document.addEventListener ('keypress', function ( e ) {
|
||||
if (e.keyCode !== 32) return ;
|
||||
|
||||
e.preventDefault ();
|
||||
e.stopPropagation ();
|
||||
|
||||
if (e.timeStamp - last_press < 100) {
|
||||
return ;
|
||||
}
|
||||
|
||||
last_press = e.timeStamp;
|
||||
|
||||
if (!iframe) {
|
||||
w.opener && w.opener.PKAudioEditor.ui.Dock ('RequestKeyDown', 32);
|
||||
}
|
||||
else {
|
||||
w.parent && w.parent.PKAudioEditor.ui.Dock ('RequestKeyDown', 32);
|
||||
}
|
||||
});
|
||||
|
||||
}, 60);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,131 @@
|
||||
// FLAC worker for encoding audio using libflac.js
|
||||
|
||||
importScripts('libflac.js');
|
||||
|
||||
var flacEncoder;
|
||||
var FLAC_INITIALIZED = false;
|
||||
var sample_rate = 44100;
|
||||
var compression = 5; // Default compression (0-8)
|
||||
var channels = 1;
|
||||
var buffers = [];
|
||||
var bufIndex = 0;
|
||||
var first_buffer = true;
|
||||
var samples_left = null;
|
||||
var samples_right = null;
|
||||
|
||||
function convert(n) {
|
||||
var v = n < 0 ? n * 32768 : n * 32767;
|
||||
return Math.max(-32768, Math.min(32768, v));
|
||||
}
|
||||
|
||||
function initFLAC() {
|
||||
if (FLAC_INITIALIZED) return true;
|
||||
|
||||
// SAMPLERATE, CHANNELS, BPS, COMPRESSION, 0, VERIFY, BLOCK_SIZE);
|
||||
flacEncoder = Flac.create_libflac_encoder(sample_rate, channels, 16, compression, 0, true, 0);
|
||||
if (flacEncoder != 0) {
|
||||
var status = Flac.init_encoder_stream(flacEncoder, function(buffer, bytes) {
|
||||
buffers.push(new Uint8Array(buffer));
|
||||
bufIndex += buffer.byteLength;
|
||||
});
|
||||
|
||||
FLAC_INITIALIZED = true;
|
||||
return status == 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function interleave(inputL, inputR) {
|
||||
var length = inputL.length + inputR.length;
|
||||
var result = new Int32Array(length);
|
||||
|
||||
var index = 0,
|
||||
inputIndex = 0;
|
||||
|
||||
while (index < length) {
|
||||
result[index++] = inputL[inputIndex];
|
||||
result[index++] = inputR[inputIndex];
|
||||
++inputIndex;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
onmessage = function(ev) {
|
||||
if (!ev.data) return;
|
||||
|
||||
if (ev.data.sample_rate) {
|
||||
sample_rate = ev.data.sample_rate / 1;
|
||||
compression = ev.data.flac_compression;
|
||||
channels = ev.data.channels / 1;
|
||||
|
||||
initFLAC();
|
||||
return;
|
||||
}
|
||||
|
||||
if (first_buffer) {
|
||||
samples_left = new Int16Array(ev.data, 0);
|
||||
first_buffer = false;
|
||||
|
||||
if (channels > 1) return;
|
||||
}
|
||||
|
||||
if (ev.data && channels > 1) {
|
||||
samples_right = new Int16Array(ev.data, 0);
|
||||
}
|
||||
|
||||
if (!FLAC_INITIALIZED) {
|
||||
postMessage({percentage: 0});
|
||||
return;
|
||||
}
|
||||
|
||||
// Progress update
|
||||
postMessage({percentage: 50});
|
||||
|
||||
// Process the audio data
|
||||
var interleaved = null;
|
||||
var samples = null;
|
||||
|
||||
if (channels > 1) {
|
||||
// Create interleaved buffer for stereo
|
||||
interleaved = interleave(samples_left, samples_right);
|
||||
samples = interleaved;
|
||||
} else {
|
||||
// Use mono buffer directly
|
||||
samples = samples_left;
|
||||
}
|
||||
|
||||
// Encode the audio data
|
||||
if (channels > 1) {
|
||||
Flac.FLAC__stream_encoder_process_interleaved(flacEncoder, samples, samples_left.length);
|
||||
} else {
|
||||
var tmp_samples = new Int32Array(samples_left.length);
|
||||
var tmp_index = 0;
|
||||
while (tmp_index < samples_left.length) {
|
||||
tmp_samples[tmp_index] = samples_left[tmp_index];
|
||||
++tmp_index;
|
||||
}
|
||||
Flac.FLAC__stream_encoder_process(flacEncoder, [tmp_samples], samples_left.length);
|
||||
}
|
||||
|
||||
// Finish encoding
|
||||
Flac.FLAC__stream_encoder_finish(flacEncoder);
|
||||
|
||||
// Combine all buffers into a single Uint8Array
|
||||
var outputData = new Uint8Array(bufIndex);
|
||||
var offset = 0;
|
||||
for (var i = 0; i < buffers.length; i++) {
|
||||
outputData.set(buffers[i], offset);
|
||||
offset += buffers[i].length;
|
||||
}
|
||||
|
||||
// Create blob and return
|
||||
var blob = new Blob([outputData], {type: 'audio/flac'});
|
||||
postMessage(blob);
|
||||
|
||||
// Clean up
|
||||
Flac.FLAC__stream_encoder_delete(flacEncoder);
|
||||
FLAC_INITIALIZED = false;
|
||||
buffers = [];
|
||||
bufIndex = 0;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" >
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<metadata>Generated by IcoMoon</metadata>
|
||||
<defs>
|
||||
<font id="icomoon" horiz-adv-x="1024">
|
||||
<font-face units-per-em="1024" ascent="960" descent="-64" />
|
||||
<missing-glyph horiz-adv-x="1024" />
|
||||
<glyph unicode=" " horiz-adv-x="512" d="" />
|
||||
<glyph unicode="" glyph-name="files-empty" d="M917.806 602.924c-22.21 30.292-53.174 65.7-87.178 99.704s-69.412 64.964-99.704 87.178c-51.574 37.82-76.592 42.194-90.924 42.194h-368c-44.114 0-80-35.888-80-80v-736c0-44.112 35.886-80 80-80h608c44.112 0 80 35.888 80 80v496c0 14.332-4.372 39.35-42.194 90.924zM785.374 657.374c30.7-30.7 54.8-58.398 72.58-81.374h-153.954v153.946c22.982-17.78 50.678-41.878 81.374-72.572v0zM896 16c0-8.672-7.328-16-16-16h-608c-8.672 0-16 7.328-16 16v736c0 8.672 7.328 16 16 16 0 0 367.956 0.002 368 0v-224c0-17.672 14.324-32 32-32h224v-496zM602.924 917.804c-51.574 37.822-76.592 42.196-90.924 42.196h-368c-44.112 0-80-35.888-80-80v-736c0-38.632 27.528-70.958 64-78.39v814.39c0 8.672 7.328 16 16 16h486.876c-9.646 7.92-19.028 15.26-27.952 21.804z" />
|
||||
<glyph unicode="" glyph-name="file-text2" d="M917.806 730.924c-22.212 30.292-53.174 65.7-87.178 99.704s-69.412 64.964-99.704 87.178c-51.574 37.82-76.592 42.194-90.924 42.194h-496c-44.112 0-80-35.888-80-80v-864c0-44.112 35.888-80 80-80h736c44.112 0 80 35.888 80 80v624c0 14.332-4.372 39.35-42.194 90.924zM785.374 785.374c30.7-30.7 54.8-58.398 72.58-81.374h-153.954v153.946c22.984-17.78 50.678-41.878 81.374-72.572zM896 16c0-8.672-7.328-16-16-16h-736c-8.672 0-16 7.328-16 16v864c0 8.672 7.328 16 16 16 0 0 495.956 0.002 496 0v-224c0-17.672 14.326-32 32-32h224v-624zM736 128h-448c-17.672 0-32 14.326-32 32s14.328 32 32 32h448c17.674 0 32-14.326 32-32s-14.326-32-32-32zM736 256h-448c-17.672 0-32 14.326-32 32s14.328 32 32 32h448c17.674 0 32-14.326 32-32s-14.326-32-32-32zM736 384h-448c-17.672 0-32 14.326-32 32s14.328 32 32 32h448c17.674 0 32-14.326 32-32s-14.326-32-32-32z" />
|
||||
<glyph unicode="" glyph-name="zoom-in" d="M992.262 88.604l-242.552 206.294c-25.074 22.566-51.89 32.926-73.552 31.926 57.256 67.068 91.842 154.078 91.842 249.176 0 212.078-171.922 384-384 384-212.076 0-384-171.922-384-384s171.922-384 384-384c95.098 0 182.108 34.586 249.176 91.844-1-21.662 9.36-48.478 31.926-73.552l206.294-242.552c35.322-39.246 93.022-42.554 128.22-7.356s31.892 92.898-7.354 128.22zM384 320c-141.384 0-256 114.616-256 256s114.616 256 256 256 256-114.616 256-256-114.614-256-256-256zM448 768h-128v-128h-128v-128h128v-128h128v128h128v128h-128z" />
|
||||
<glyph unicode="" glyph-name="zoom-out" d="M992.262 88.604l-242.552 206.294c-25.074 22.566-51.89 32.926-73.552 31.926 57.256 67.068 91.842 154.078 91.842 249.176 0 212.078-171.922 384-384 384-212.076 0-384-171.922-384-384s171.922-384 384-384c95.098 0 182.108 34.586 249.176 91.844-1-21.662 9.36-48.478 31.926-73.552l206.294-242.552c35.322-39.246 93.022-42.554 128.22-7.356s31.892 92.898-7.354 128.22zM384 320c-141.384 0-256 114.616-256 256s114.616 256 256 256 256-114.616 256-256-114.614-256-256-256zM192 640h384v-128h-384z" />
|
||||
<glyph unicode="" glyph-name="hammer" d="M1009.996 131.024l-301.544 301.544c-18.668 18.668-49.214 18.668-67.882 0l-22.626-22.626-184 184 302.056 302.058h-320l-142.058-142.058-14.060 14.058h-67.882v-67.882l14.058-14.058-206.058-206.060 160-160 206.058 206.058 184-184-22.626-22.626c-18.668-18.668-18.668-49.214 0-67.882l301.544-301.544c18.668-18.668 49.214-18.668 67.882 0l113.136 113.136c18.67 18.666 18.67 49.214 0.002 67.882z" />
|
||||
<glyph unicode="" glyph-name="play3" d="M192 832l640-384-640-384z" />
|
||||
<glyph unicode="" glyph-name="pause2" d="M128 832h320v-768h-320zM576 832h320v-768h-320z" />
|
||||
<glyph unicode="" glyph-name="stop2" d="M128 832h768v-768h-768z" />
|
||||
<glyph unicode="" glyph-name="backward2" d="M576 800v-320l320 320v-704l-320 320v-320l-352 352z" />
|
||||
<glyph unicode="" glyph-name="forward3" d="M512 96v320l-320-320v704l320-320v320l352-352z" />
|
||||
<glyph unicode="" glyph-name="first" d="M128 64v768h128v-352l320 320v-320l320 320v-704l-320 320v-320l-320 320v-352z" />
|
||||
<glyph unicode="" glyph-name="last" d="M896 832v-768h-128v352l-320-320v320l-320-320v704l320-320v320l320-320v352z" />
|
||||
<glyph unicode="" glyph-name="previous2" d="M256 64v768h128v-352l320 320v-704l-320 320v-352z" />
|
||||
<glyph unicode="" glyph-name="next2" d="M768 832v-768h-128v352l-320-320v704l320-320v352z" />
|
||||
<glyph unicode="" glyph-name="loop" d="M128 640h640v-192l256 256-256 256v-192h-768v-384h128zM896 256h-640v192l-256-256 256-256v192h768v384h-128z" />
|
||||
<glyph unicode="" glyph-name="scissors" d="M913.826 280.306c-66.684 104.204-181.078 150.064-255.51 102.434-6.428-4.116-12.334-8.804-17.744-13.982l-79.452 124.262 183.462 287.972c15.016 27.73 20.558 60.758 13.266 93.974-6.972 31.75-24.516 58.438-48.102 77.226l-12.278 7.808-217.468-340.114-217.47 340.114-12.276-7.806c-23.586-18.79-41.13-45.476-48.1-77.226-7.292-33.216-1.75-66.244 13.264-93.974l183.464-287.972-79.454-124.262c-5.41 5.178-11.316 9.868-17.744 13.982-74.432 47.63-188.826 1.77-255.51-102.434-66.68-104.2-60.398-227.286 14.032-274.914 74.43-47.632 188.824-1.77 255.508 102.432l164.286 257.87 164.288-257.872c66.684-104.202 181.078-150.064 255.508-102.432 74.428 47.63 80.71 170.716 14.030 274.914zM234.852 159.57c-30.018-46.904-68.534-69.726-94.572-75.446-0.004 0-0.004 0-0.004 0-8.49-1.868-20.294-3.010-28.324 2.128-8.898 5.694-14.804 20.748-15.8 40.276-1.616 31.644 9.642 68.836 30.888 102.034 30.014 46.906 68.53 69.726 94.562 75.444 8.496 1.866 20.308 3.010 28.336-2.126 8.898-5.694 14.802-20.75 15.798-40.272 1.618-31.65-9.64-68.84-30.884-102.038zM480 448c-17.672 0-32 14.328-32 32s14.328 32 32 32 32-14.328 32-32-14.328-32-32-32zM863.85 126.53c-0.996-19.528-6.902-34.582-15.8-40.276-8.030-5.138-19.834-3.996-28.324-2.128 0 0 0 0-0.004 0-26.040 5.718-64.554 28.542-94.572 75.446-21.244 33.198-32.502 70.388-30.884 102.038 0.996 19.522 6.9 34.578 15.798 40.272 8.028 5.136 19.84 3.992 28.336 2.126 26.034-5.716 64.548-28.538 94.562-75.444 21.246-33.198 32.502-70.39 30.888-102.034z" />
|
||||
</font></defs></svg>
|
||||
|
After Width: | Height: | Size: 6.1 KiB |
@@ -0,0 +1,457 @@
|
||||
(function ( w, d, PKAE ) {
|
||||
'use strict';
|
||||
|
||||
var _pid = 0;
|
||||
var _aid = 0;
|
||||
|
||||
function FXAutomation ( app, filter_modal, val_cb, preview_cb ) {
|
||||
var q = this;
|
||||
|
||||
q.modal = filter_modal;
|
||||
q.app = app;
|
||||
q.wv = app.engine.wavesurfer;
|
||||
q.points = {};
|
||||
q.act = null;
|
||||
q.act_point = null;
|
||||
q.in_auto = false;
|
||||
q.rbuff = null;
|
||||
|
||||
q.btn_auto = _make_btn_auto ( q );
|
||||
|
||||
q.GetValue = function () {
|
||||
var data = [];
|
||||
|
||||
var inputs = q.modal.el_body.getElementsByTagName('input');
|
||||
var plen = q.points.length;
|
||||
|
||||
for (var i = 0; i < inputs.length; ++i)
|
||||
{
|
||||
var curr = inputs[i];
|
||||
if (q.points[curr.id])
|
||||
{
|
||||
var arr = [];
|
||||
var p = q.points[curr.id];
|
||||
for (var j = 0; j < p.length; ++j)
|
||||
{
|
||||
var tmp = {
|
||||
time: p[j].time,
|
||||
val: p[j].val
|
||||
};
|
||||
arr.push(tmp);
|
||||
val_cb && val_cb (tmp, curr);
|
||||
}
|
||||
data.push (arr);
|
||||
}
|
||||
else
|
||||
{
|
||||
var tmp = {
|
||||
val: curr.value
|
||||
};
|
||||
|
||||
data.push (tmp);
|
||||
val_cb && val_cb (tmp, curr);
|
||||
}
|
||||
}
|
||||
|
||||
return (data);
|
||||
};
|
||||
|
||||
q.cw = 500;
|
||||
q.ch = 200;
|
||||
var els = _make_canvas ( q, q.cw, q.ch );
|
||||
q.canvas = els[0];
|
||||
q.ctx = els[1];
|
||||
|
||||
|
||||
var _fillstyle = '#d9d955';
|
||||
q.Render = function () {
|
||||
var ctx = q.ctx;
|
||||
var cw = q.cw;
|
||||
var ch = q.ch;
|
||||
|
||||
if (q.rbuff)
|
||||
q.app.engine.GetWave (q.rbuff, 500, 200, null, null, q.canvas, q.ctx);
|
||||
|
||||
// ctx.clearRect (0, 0, q.cw, q.ch);
|
||||
ctx.fillStyle = _fillstyle;
|
||||
ctx.strokeStyle = '#FF0000';
|
||||
|
||||
if (!q.act) return ;
|
||||
|
||||
ctx.beginPath ();
|
||||
ctx.moveTo ( 0, ch / 2 );
|
||||
var last_y = ch / 2;
|
||||
|
||||
for (var o = 0; o < q.points[q.act.id].length; ++o)
|
||||
{
|
||||
var curr = q.points[q.act.id][ o ];
|
||||
|
||||
var center_x = curr.ax;
|
||||
var center_y = curr.ay;
|
||||
|
||||
ctx.lineTo ( center_x, center_y );
|
||||
last_y = center_y;
|
||||
}
|
||||
|
||||
ctx.lineTo ( cw, last_y );
|
||||
ctx.stroke ();
|
||||
|
||||
var radius = 6;
|
||||
for (var o = 0; o < q.points[q.act.id].length; ++o)
|
||||
{
|
||||
var curr = q.points[q.act.id][ o ];
|
||||
|
||||
var center_x = curr.ax;
|
||||
var center_y = curr.ay;
|
||||
|
||||
ctx.beginPath ();
|
||||
ctx.arc (center_x, center_y, radius, 0, 2 * Math.PI, false);
|
||||
|
||||
if (curr === q.act_point) {
|
||||
ctx.shadowBlur = 24;
|
||||
|
||||
if (curr._on)
|
||||
ctx.fillStyle = '#fff';
|
||||
else
|
||||
ctx.fillStyle = '#686868';
|
||||
|
||||
ctx.stroke ();
|
||||
ctx.fill ();
|
||||
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.fillStyle = _fillstyle;
|
||||
}
|
||||
else if (curr._hov) {
|
||||
|
||||
if (curr._on)
|
||||
ctx.fillStyle = 'blue';
|
||||
else
|
||||
ctx.fillStyle = 'darkblue';
|
||||
|
||||
ctx.stroke ();
|
||||
ctx.fill ();
|
||||
|
||||
ctx.fillStyle = _fillstyle;
|
||||
}
|
||||
else if (curr._on) {
|
||||
ctx.fill ();
|
||||
}
|
||||
else {
|
||||
ctx.fillStyle = '#555';
|
||||
ctx.fill ();
|
||||
ctx.fillStyle = _fillstyle;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
_make_controls ( q );
|
||||
|
||||
// -------
|
||||
function _make_controls ( q ) {
|
||||
var click_time = 0;
|
||||
q.canvas.addEventListener ('click', function ( e ) {
|
||||
if (!q.act) return;
|
||||
if (e.timeStamp - click_time < 260)
|
||||
{
|
||||
var bounds = q.canvas.getBoundingClientRect ();
|
||||
var cw = q.cw;
|
||||
var ch = q.ch;
|
||||
var posx = e.clientX - bounds.left;
|
||||
var posy = e.clientY - bounds.top;
|
||||
|
||||
var rel_x = posx / cw;
|
||||
var rel_y = posy / ch;
|
||||
|
||||
if (!q.points[q.act.id]) q.points[q.act.id] = [];
|
||||
|
||||
var duration;
|
||||
var region = q.wv.regions.list[0];
|
||||
if (region) {
|
||||
duration = region.end - region.start;
|
||||
} else {
|
||||
duration = q.wv.getDuration();
|
||||
}
|
||||
|
||||
q.points[q.act.id].push ({
|
||||
// el:q.act.el,
|
||||
id: ++_pid,
|
||||
x: rel_x,
|
||||
y: rel_y,
|
||||
ax: rel_x * cw,
|
||||
ay: rel_y * ch,
|
||||
time: duration * rel_x,
|
||||
val : ((1 - rel_y) * (q.act.max - q.act.min)) + q.act.min,
|
||||
_on: true,
|
||||
_hov: false,
|
||||
});
|
||||
|
||||
q.points[q.act.id].sort( _compare );
|
||||
q.act_point = q.points[q.act.id][q.points[q.act.id].length - 1];
|
||||
|
||||
//_process ( q, q.wv.backend.buffer );
|
||||
|
||||
q.Render ();
|
||||
// ----
|
||||
}
|
||||
|
||||
click_time = e.timeStamp;
|
||||
}, false);
|
||||
|
||||
var is_dragging = false;
|
||||
var skip = 3;
|
||||
q.canvas.addEventListener ('mousemove', function ( e ) {
|
||||
if (!is_dragging || !q.act_point) return ;
|
||||
|
||||
var ex = 0;
|
||||
var ey = 0;
|
||||
|
||||
if (e.touches) {
|
||||
if (e.touches.length > 1) { return ; }
|
||||
|
||||
ex = e.touches[0].clientX;
|
||||
ey = e.touches[0].clientY;
|
||||
} else {
|
||||
ex = e.clientX;
|
||||
ey = e.clientY;
|
||||
}
|
||||
|
||||
var bounds = q.canvas.getBoundingClientRect ();
|
||||
var cw = q.cw;
|
||||
var ch = q.ch;
|
||||
|
||||
var posx = ex - bounds.left;
|
||||
var posy = ey - bounds.top;
|
||||
|
||||
var rel_x = posx / cw;
|
||||
var rel_y = posy / ch;
|
||||
|
||||
q.act_point.ax = posx;
|
||||
q.act_point.ay = posy;
|
||||
|
||||
q.act_point.x = rel_x;
|
||||
q.act_point.y = rel_y;
|
||||
|
||||
var duration;
|
||||
var region = q.wv.regions.list[0];
|
||||
if (region) {
|
||||
duration = region.end - region.start;
|
||||
} else {
|
||||
duration = q.wv.getDuration();
|
||||
}
|
||||
|
||||
q.act_point.time = duration * rel_x;
|
||||
q.act_point.val = ((1 - rel_y) * q.act.max - q.act.min) + q.act.min;
|
||||
|
||||
q.Render ();
|
||||
|
||||
if (--skip === 0) {
|
||||
skip = 4;
|
||||
_process ( q, q.wv.backend.buffer );
|
||||
}
|
||||
});
|
||||
|
||||
q.canvas.addEventListener ('mousedown', function ( e ) {
|
||||
is_dragging = false;
|
||||
if (!q.act) return ;
|
||||
|
||||
var bounds = q.canvas.getBoundingClientRect ();
|
||||
var cw = q.cw;
|
||||
var ch = q.ch;
|
||||
|
||||
var posx = e.clientX - bounds.left;
|
||||
var posy = e.clientY - bounds.top;
|
||||
|
||||
var dist_x = e.is_touch ? 20 : 10;
|
||||
var dist_y = e.is_touch ? 20 : 9;
|
||||
|
||||
if (!q.points[q.act.id]) q.points[q.act.id] = [];
|
||||
|
||||
for (var o = 0; o < q.points[q.act.id].length; ++o)
|
||||
{
|
||||
var curr = q.points[q.act.id][ o ];
|
||||
if ( Math.abs (curr.ax - posx) < dist_x && Math.abs (curr.ay - posy) < dist_y)
|
||||
{
|
||||
is_dragging = true;
|
||||
q.act_point = curr;
|
||||
q.Render ();
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_dragging)
|
||||
{
|
||||
q.act_point = null;
|
||||
q.Render ();
|
||||
}
|
||||
});
|
||||
|
||||
q.canvas.addEventListener ('mouseup', function ( e ) {
|
||||
is_dragging = false;
|
||||
});
|
||||
|
||||
|
||||
var act_el = null;
|
||||
q.modal.el_body.addEventListener ('mouseover', function(e) {
|
||||
if (!q.in_auto) return ;
|
||||
if (e.target.tagName === 'INPUT') {
|
||||
e.target.classList.add ('pk_aut');
|
||||
}
|
||||
});
|
||||
q.modal.el_body.addEventListener ('mouseout', function(e) {
|
||||
if (!q.in_auto) return ;
|
||||
if (e.target.tagName === 'INPUT') {
|
||||
e.target.classList.remove ('pk_aut');
|
||||
}
|
||||
});
|
||||
q.modal.el_body.addEventListener ('click', function(e) {
|
||||
if (!q.in_auto) return ;
|
||||
if (e.target.classList.contains ('pk_aut'))
|
||||
{
|
||||
if (act_el) {
|
||||
act_el.classList.remove ('pk_aut_act');
|
||||
act_el = null;
|
||||
}
|
||||
|
||||
e.target.classList.add ('pk_aut_act');
|
||||
act_el = e.target;
|
||||
|
||||
if (!e.target.id) e.target.id = 'pk' + (++_aid);
|
||||
|
||||
q.act = {
|
||||
id: e.target.id,
|
||||
el: e.target,
|
||||
type:e.target.range,
|
||||
min:e.target.min/1,
|
||||
max:e.target.max/1,
|
||||
step:e.target.step/1
|
||||
};
|
||||
|
||||
if (!q.points[q.act.id]) q.points[q.act.id] = [];
|
||||
|
||||
q.Render ();
|
||||
}
|
||||
|
||||
// console.log( 'click ', e.target );
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
function _make_btn_auto ( q ) {
|
||||
var btn_automate = d.createElement ('a');
|
||||
btn_automate.className = 'pk_modal_a_bottom';
|
||||
btn_automate.innerHTML = 'AUTOMATE';
|
||||
|
||||
var in_auto = false;
|
||||
btn_automate.onclick = function () {
|
||||
q.in_auto = !q.in_auto;
|
||||
|
||||
if (q.in_auto) {
|
||||
btn_automate.classList.add ('pk_act');
|
||||
} else {
|
||||
btn_automate.classList.remove ('pk_act');
|
||||
}
|
||||
};
|
||||
|
||||
q.modal.el_body.appendChild( btn_automate );
|
||||
|
||||
return (btn_automate);
|
||||
};
|
||||
|
||||
function _make_canvas ( q ) {
|
||||
var cc = document.createElement ('canvas');
|
||||
cc.width = 500; cc.height = 200;
|
||||
cc.style.background = '#000';
|
||||
var ctx = cc.getContext('2d');
|
||||
|
||||
q.modal.el_body.appendChild( cc );
|
||||
|
||||
var buff = q.wv.backend.buffer;
|
||||
|
||||
var img = new Image();
|
||||
img.onload = function () {
|
||||
ctx.drawImage (img, 0, 0);
|
||||
};
|
||||
|
||||
var offset; var length;
|
||||
var region = q.wv.regions.list[0];
|
||||
if (region) {
|
||||
offset = (region.start * buff.sampleRate) >> 0;
|
||||
length = (region.end * buff.sampleRate) >> 0;
|
||||
}
|
||||
|
||||
_process ( q, buff );
|
||||
|
||||
img.src = q.app.engine.GetWave (buff, 500, 200, offset, length);
|
||||
|
||||
return ([cc, ctx]);
|
||||
};
|
||||
|
||||
function _compare ( a, b ) {
|
||||
if (a.x > b.x) return 1;
|
||||
return -1;
|
||||
};
|
||||
|
||||
function _process ( q, buffer ) {
|
||||
var getOfflineAudioContext = function (channels, sampleRate, duration) {
|
||||
return new (window.OfflineAudioContext ||
|
||||
window.webkitOfflineAudioContext)(channels, duration, sampleRate);
|
||||
};
|
||||
|
||||
var region = q.wv.regions.list[0];
|
||||
var offs = 0;
|
||||
var durr = buffer.duration;
|
||||
if (region) {
|
||||
offs = region.start;
|
||||
durr = region.end - region.start;
|
||||
}
|
||||
|
||||
var audio_ctx = getOfflineAudioContext (
|
||||
1, // orig_buffer.numberOfChannels,
|
||||
8000,
|
||||
(durr * 8000) >> 0
|
||||
);
|
||||
|
||||
var newbuffer = audio_ctx.createBuffer (1, durr * buffer.sampleRate, buffer.sampleRate);
|
||||
newbuffer.getChannelData ( 0 ).set (
|
||||
buffer.getChannelData ( 0 ).slice ( (offs * buffer.sampleRate) / 4, ((offs + durr) * buffer.sampleRate)/4 )
|
||||
);
|
||||
|
||||
var source = audio_ctx.createBufferSource ();
|
||||
source.buffer = newbuffer;
|
||||
|
||||
//var fx = q.app.engine.GetFX ('Gain', q.GetValue ());
|
||||
//console.log ( fx.filter ( audio_ctx, audio_ctx.destination, source ) );
|
||||
|
||||
source.connect (audio_ctx.destination);
|
||||
source.start (0); //, offs, durr);
|
||||
|
||||
var offline_callback = function( rendered_buffer ) {
|
||||
q.rbuff = rendered_buffer;
|
||||
|
||||
debugger;
|
||||
|
||||
// var img = new Image();
|
||||
// img.src = q.app.engine.GetWave (rendered_buffer, 500, 200);
|
||||
|
||||
q.Render ();
|
||||
|
||||
};
|
||||
var offline_renderer = audio_ctx.startRendering();
|
||||
if (offline_renderer)
|
||||
offline_renderer.then( offline_callback ).catch(function(err) {
|
||||
console.log('Rendering failed: ' + err);
|
||||
});
|
||||
else
|
||||
audio_ctx.oncomplete = function ( e ) {
|
||||
offline_callback ( e.renderedBuffer );
|
||||
};
|
||||
|
||||
// ---------
|
||||
};
|
||||
};
|
||||
|
||||
PKAudioEditor._deps.FxAUT = FXAutomation;
|
||||
|
||||
})( window, document, PKAudioEditor );
|
||||
|
After Width: | Height: | Size: 514 B |
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,856 @@
|
||||
(function ( w, d, PKAE ) {
|
||||
'use strict';
|
||||
|
||||
|
||||
var StringUtils = {
|
||||
readUTF16String: function(bytes, bigEndian, maxBytes) {
|
||||
var ix = 0;
|
||||
var offset1 = 1, offset2 = 0;
|
||||
maxBytes = Math.min(maxBytes||bytes.length, bytes.length);
|
||||
|
||||
if( bytes[0] == 0xFE && bytes[1] == 0xFF ) {
|
||||
bigEndian = true;
|
||||
ix = 2;
|
||||
} else if( bytes[0] == 0xFF && bytes[1] == 0xFE ) {
|
||||
bigEndian = false;
|
||||
ix = 2;
|
||||
}
|
||||
if( bigEndian ) {
|
||||
offset1 = 0;
|
||||
offset2 = 1;
|
||||
}
|
||||
|
||||
var arr = [];
|
||||
for( var j = 0; ix < maxBytes; j++ ) {
|
||||
var byte1 = bytes[ix+offset1];
|
||||
var byte2 = bytes[ix+offset2];
|
||||
var word1 = (byte1<<8)+byte2;
|
||||
ix += 2;
|
||||
if( word1 == 0x0000 ) {
|
||||
break;
|
||||
} else if( byte1 < 0xD8 || byte1 >= 0xE0 ) {
|
||||
arr[j] = String.fromCharCode(word1);
|
||||
} else {
|
||||
var byte3 = bytes[ix+offset1];
|
||||
var byte4 = bytes[ix+offset2];
|
||||
var word2 = (byte3<<8)+byte4;
|
||||
ix += 2;
|
||||
arr[j] = String.fromCharCode(word1, word2);
|
||||
}
|
||||
}
|
||||
var string = new String(arr.join(""));
|
||||
string.bytesReadCount = ix;
|
||||
return string;
|
||||
},
|
||||
readUTF8String: function(bytes, maxBytes) {
|
||||
var ix = 0;
|
||||
maxBytes = Math.min(maxBytes||bytes.length, bytes.length);
|
||||
|
||||
if( bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF ) {
|
||||
ix = 3;
|
||||
}
|
||||
|
||||
var arr = [];
|
||||
for( var j = 0; ix < maxBytes; j++ ) {
|
||||
var byte1 = bytes[ix++];
|
||||
if( byte1 == 0x00 ) {
|
||||
break;
|
||||
} else if( byte1 < 0x80 ) {
|
||||
arr[j] = String.fromCharCode(byte1);
|
||||
} else if( byte1 >= 0xC2 && byte1 < 0xE0 ) {
|
||||
var byte2 = bytes[ix++];
|
||||
arr[j] = String.fromCharCode(((byte1&0x1F)<<6) + (byte2&0x3F));
|
||||
} else if( byte1 >= 0xE0 && byte1 < 0xF0 ) {
|
||||
var byte2 = bytes[ix++];
|
||||
var byte3 = bytes[ix++];
|
||||
arr[j] = String.fromCharCode(((byte1&0xFF)<<12) + ((byte2&0x3F)<<6) + (byte3&0x3F));
|
||||
} else if( byte1 >= 0xF0 && byte1 < 0xF5) {
|
||||
var byte2 = bytes[ix++];
|
||||
var byte3 = bytes[ix++];
|
||||
var byte4 = bytes[ix++];
|
||||
var codepoint = ((byte1&0x07)<<18) + ((byte2&0x3F)<<12)+ ((byte3&0x3F)<<6) + (byte4&0x3F) - 0x10000;
|
||||
arr[j] = String.fromCharCode(
|
||||
(codepoint>>10) + 0xD800,
|
||||
(codepoint&0x3FF) + 0xDC00
|
||||
);
|
||||
}
|
||||
}
|
||||
var string = new String(arr.join(""));
|
||||
string.bytesReadCount = ix;
|
||||
return string;
|
||||
},
|
||||
readNullTerminatedString: function(bytes, maxBytes) {
|
||||
var arr = [];
|
||||
maxBytes = maxBytes || bytes.length;
|
||||
for ( var i = 0; i < maxBytes; ) {
|
||||
var byte1 = bytes[i++];
|
||||
if( byte1 == 0x00 ) break;
|
||||
arr[i-1] = String.fromCharCode(byte1);
|
||||
}
|
||||
var string = new String(arr.join(""));
|
||||
string.bytesReadCount = i;
|
||||
return string;
|
||||
}
|
||||
};
|
||||
|
||||
var getBytesAt = function(data, iOffset, iLength) {
|
||||
var bytes = new Array(iLength);
|
||||
for( var i = 0; i < iLength; i++ ) {
|
||||
bytes[i] = data.getUint8(iOffset+i);
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
var getStringWithCharsetAt = function(data, iOffset, iLength, iCharset) {
|
||||
var bytes = getBytesAt(data, iOffset, iLength);
|
||||
var sString;
|
||||
|
||||
switch( iCharset.toLowerCase() ) {
|
||||
case 'utf-16':
|
||||
case 'utf-16le':
|
||||
case 'utf-16be':
|
||||
sString = StringUtils.readUTF16String(bytes, iCharset);
|
||||
break;
|
||||
|
||||
case 'utf-8':
|
||||
sString = StringUtils.readUTF8String(bytes);
|
||||
break;
|
||||
|
||||
default:
|
||||
sString = StringUtils.readNullTerminatedString(bytes);
|
||||
break;
|
||||
}
|
||||
|
||||
return sString;
|
||||
};
|
||||
|
||||
var ID3v2 = {
|
||||
readFrameData: {}
|
||||
};
|
||||
|
||||
ID3v2.frames = {
|
||||
// v2.2
|
||||
"BUF" : "Recommended buffer size",
|
||||
"CNT" : "Play counter",
|
||||
"COM" : "Comments",
|
||||
"CRA" : "Audio encryption",
|
||||
"CRM" : "Encrypted meta frame",
|
||||
"ETC" : "Event timing codes",
|
||||
"EQU" : "Equalization",
|
||||
"GEO" : "General encapsulated object",
|
||||
"IPL" : "Involved people list",
|
||||
"LNK" : "Linked information",
|
||||
"MCI" : "Music CD Identifier",
|
||||
"MLL" : "MPEG location lookup table",
|
||||
"PIC" : "Attached picture",
|
||||
"POP" : "Popularimeter",
|
||||
"REV" : "Reverb",
|
||||
"RVA" : "Relative volume adjustment",
|
||||
"SLT" : "Synchronized lyric/text",
|
||||
"STC" : "Synced tempo codes",
|
||||
"TAL" : "Album/Movie/Show title",
|
||||
"TBP" : "BPM (Beats Per Minute)",
|
||||
"TCM" : "Composer",
|
||||
"TCO" : "Content type",
|
||||
"TCR" : "Copyright message",
|
||||
"TDA" : "Date",
|
||||
"TDY" : "Playlist delay",
|
||||
"TEN" : "Encoded by",
|
||||
"TFT" : "File type",
|
||||
"TIM" : "Time",
|
||||
"TKE" : "Initial key",
|
||||
"TLA" : "Language(s)",
|
||||
"TLE" : "Length",
|
||||
"TMT" : "Media type",
|
||||
"TOA" : "Original artist(s)/performer(s)",
|
||||
"TOF" : "Original filename",
|
||||
"TOL" : "Original Lyricist(s)/text writer(s)",
|
||||
"TOR" : "Original release year",
|
||||
"TOT" : "Original album/Movie/Show title",
|
||||
"TP1" : "Lead artist(s)/Lead performer(s)/Soloist(s)/Performing group",
|
||||
"TP2" : "Band/Orchestra/Accompaniment",
|
||||
"TP3" : "Conductor/Performer refinement",
|
||||
"TP4" : "Interpreted, remixed, or otherwise modified by",
|
||||
"TPA" : "Part of a set",
|
||||
"TPB" : "Publisher",
|
||||
"TRC" : "ISRC (International Standard Recording Code)",
|
||||
"TRD" : "Recording dates",
|
||||
"TRK" : "Track number/Position in set",
|
||||
"TSI" : "Size",
|
||||
"TSS" : "Software/hardware and settings used for encoding",
|
||||
"TT1" : "Content group description",
|
||||
"TT2" : "Title/Songname/Content description",
|
||||
"TT3" : "Subtitle/Description refinement",
|
||||
"TXT" : "Lyricist/text writer",
|
||||
"TXX" : "User defined text information frame",
|
||||
"TYE" : "Year",
|
||||
"UFI" : "Unique file identifier",
|
||||
"ULT" : "Unsychronized lyric/text transcription",
|
||||
"WAF" : "Official audio file webpage",
|
||||
"WAR" : "Official artist/performer webpage",
|
||||
"WAS" : "Official audio source webpage",
|
||||
"WCM" : "Commercial information",
|
||||
"WCP" : "Copyright/Legal information",
|
||||
"WPB" : "Publishers official webpage",
|
||||
"WXX" : "User defined URL link frame",
|
||||
// v2.3
|
||||
"AENC" : "Audio encryption",
|
||||
"APIC" : "Attached picture",
|
||||
"COMM" : "Comments",
|
||||
"COMR" : "Commercial frame",
|
||||
"ENCR" : "Encryption method registration",
|
||||
"EQUA" : "Equalization",
|
||||
"ETCO" : "Event timing codes",
|
||||
"GEOB" : "General encapsulated object",
|
||||
"GRID" : "Group identification registration",
|
||||
"IPLS" : "Involved people list",
|
||||
"LINK" : "Linked information",
|
||||
"MCDI" : "Music CD identifier",
|
||||
"MLLT" : "MPEG location lookup table",
|
||||
"OWNE" : "Ownership frame",
|
||||
"PRIV" : "Private frame",
|
||||
"PCNT" : "Play counter",
|
||||
"POPM" : "Popularimeter",
|
||||
"POSS" : "Position synchronisation frame",
|
||||
"RBUF" : "Recommended buffer size",
|
||||
"RVAD" : "Relative volume adjustment",
|
||||
"RVRB" : "Reverb",
|
||||
"SYLT" : "Synchronized lyric/text",
|
||||
"SYTC" : "Synchronized tempo codes",
|
||||
"TALB" : "Album/Movie/Show title",
|
||||
"TBPM" : "BPM (beats per minute)",
|
||||
"TCOM" : "Composer",
|
||||
"TCON" : "Content type",
|
||||
"TCOP" : "Copyright message",
|
||||
"TDAT" : "Date",
|
||||
"TDLY" : "Playlist delay",
|
||||
"TENC" : "Encoded by",
|
||||
"TEXT" : "Lyricist/Text writer",
|
||||
"TFLT" : "File type",
|
||||
"TIME" : "Time",
|
||||
"TIT1" : "Content group description",
|
||||
"TIT2" : "Title/songname/content description",
|
||||
"TIT3" : "Subtitle/Description refinement",
|
||||
"TKEY" : "Initial key",
|
||||
"TLAN" : "Language(s)",
|
||||
"TLEN" : "Length",
|
||||
"TMED" : "Media type",
|
||||
"TOAL" : "Original album/movie/show title",
|
||||
"TOFN" : "Original filename",
|
||||
"TOLY" : "Original lyricist(s)/text writer(s)",
|
||||
"TOPE" : "Original artist(s)/performer(s)",
|
||||
"TORY" : "Original release year",
|
||||
"TOWN" : "File owner/licensee",
|
||||
"TPE1" : "Lead performer(s)/Soloist(s)",
|
||||
"TPE2" : "Band/orchestra/accompaniment",
|
||||
"TPE3" : "Conductor/performer refinement",
|
||||
"TPE4" : "Interpreted, remixed, or otherwise modified by",
|
||||
"TPOS" : "Part of a set",
|
||||
"TPUB" : "Publisher",
|
||||
"TRCK" : "Track number/Position in set",
|
||||
"TRDA" : "Recording dates",
|
||||
"TRSN" : "Internet radio station name",
|
||||
"TRSO" : "Internet radio station owner",
|
||||
"TSIZ" : "Size",
|
||||
"TSRC" : "ISRC (international standard recording code)",
|
||||
"TSSE" : "Software/Hardware and settings used for encoding",
|
||||
"TYER" : "Year",
|
||||
"TXXX" : "User defined text information frame",
|
||||
"UFID" : "Unique file identifier",
|
||||
"USER" : "Terms of use",
|
||||
"USLT" : "Unsychronized lyric/text transcription",
|
||||
"WCOM" : "Commercial information",
|
||||
"WCOP" : "Copyright/Legal information",
|
||||
"WOAF" : "Official audio file webpage",
|
||||
"WOAR" : "Official artist/performer webpage",
|
||||
"WOAS" : "Official audio source webpage",
|
||||
"WORS" : "Official internet radio station homepage",
|
||||
"WPAY" : "Payment",
|
||||
"WPUB" : "Publishers official webpage",
|
||||
"WXXX" : "User defined URL link frame"
|
||||
};
|
||||
|
||||
var pictureType = [
|
||||
"32x32 pixels 'file icon' (PNG only)",
|
||||
"Other file icon",
|
||||
"Cover (front)",
|
||||
"Cover (back)",
|
||||
"Leaflet page",
|
||||
"Media (e.g. lable side of CD)",
|
||||
"Lead artist/lead performer/soloist",
|
||||
"Artist/performer",
|
||||
"Conductor",
|
||||
"Band/Orchestra",
|
||||
"Composer",
|
||||
"Lyricist/text writer",
|
||||
"Recording Location",
|
||||
"During recording",
|
||||
"During performance",
|
||||
"Movie/video screen capture",
|
||||
"A bright coloured fish",
|
||||
"Illustration",
|
||||
"Band/artist logotype",
|
||||
"Publisher/Studio logotype"
|
||||
];
|
||||
|
||||
var getStringAt = function(data, iOffset, iLength) {
|
||||
var aStr = [];
|
||||
for (var i=iOffset,j=0;i<iOffset+iLength;i++,j++) {
|
||||
aStr[j] = String.fromCharCode(data.getUint8(i));
|
||||
}
|
||||
return aStr.join("");
|
||||
};
|
||||
var getLongAt = function(data, iOffset, bBigEndian) {
|
||||
var iByte1 = data.getUint8(iOffset),
|
||||
iByte2 = data.getUint8(iOffset + 1),
|
||||
iByte3 = data.getUint8(iOffset + 2),
|
||||
iByte4 = data.getUint8(iOffset + 3);
|
||||
|
||||
var iLong = bBigEndian ?
|
||||
(((((iByte1 << 8) + iByte2) << 8) + iByte3) << 8) + iByte4
|
||||
: (((((iByte4 << 8) + iByte3) << 8) + iByte2) << 8) + iByte1;
|
||||
if (iLong < 0) iLong += 4294967296;
|
||||
return iLong;
|
||||
};
|
||||
var getSLongAt = function(data, iOffset, bBigEndian) {
|
||||
var iULong = getLongAt(data, iOffset, bBigEndian);
|
||||
if (iULong > 2147483647)
|
||||
return iULong - 4294967296;
|
||||
else
|
||||
return iULong;
|
||||
};
|
||||
var getShortAt = function(data, iOffset, bBigEndian) {
|
||||
var iShort = bBigEndian ?
|
||||
(data.getUint8(iOffset) << 8) + data.getUint8(iOffset + 1)
|
||||
: (data.getUint8(iOffset + 1) << 8) + data.getUint8(iOffset);
|
||||
if (iShort < 0) iShort += 65536;
|
||||
return iShort;
|
||||
};
|
||||
var getInteger24At = function(data, iOffset, bBigEndian) {
|
||||
var iByte1 = data.getUint8(iOffset),
|
||||
iByte2 = data.getUint8(iOffset + 1),
|
||||
iByte3 = data.getUint8(iOffset + 2);
|
||||
|
||||
var iInteger = bBigEndian ?
|
||||
((((iByte1 << 8) + iByte2) << 8) + iByte3)
|
||||
: ((((iByte3 << 8) + iByte2) << 8) + iByte1);
|
||||
if (iInteger < 0) iInteger += 16777216;
|
||||
return iInteger;
|
||||
};
|
||||
var isBitSetAt = function ( dataview, offset, bit ) {
|
||||
var ibyte = dataview.getUint8(offset);
|
||||
return (ibyte & (1 << bit)) != 0;
|
||||
};
|
||||
var readSynchsafeInteger32At = function (offset, data) {
|
||||
var size1 = data.getUint8(offset);
|
||||
var size2 = data.getUint8(offset+1);
|
||||
var size3 = data.getUint8(offset+2);
|
||||
var size4 = data.getUint8(offset+3);
|
||||
// 0x7f = 0b01111111
|
||||
var size = size4 & 0x7f
|
||||
| ((size3 & 0x7f) << 7)
|
||||
| ((size2 & 0x7f) << 14)
|
||||
| ((size1 & 0x7f) << 21);
|
||||
|
||||
return size;
|
||||
};
|
||||
var readFrameFlags = function(data, offset) {
|
||||
var flags =
|
||||
{
|
||||
message:
|
||||
{
|
||||
tag_alter_preservation : isBitSetAt(data, offset, 6),
|
||||
file_alter_preservation : isBitSetAt(data, offset, 5),
|
||||
read_only : isBitSetAt(data, offset, 4)
|
||||
},
|
||||
format:
|
||||
{
|
||||
grouping_identity : isBitSetAt(data, offset+1, 7),
|
||||
compression : isBitSetAt(data, offset+1, 3),
|
||||
encription : isBitSetAt(data, offset+1, 2),
|
||||
unsynchronisation : isBitSetAt(data, offset+1, 1),
|
||||
data_length_indicator : isBitSetAt(data, offset+1, 0)
|
||||
}
|
||||
};
|
||||
|
||||
return flags;
|
||||
};
|
||||
var _shortcuts = {
|
||||
"title" : ["TIT2", "TT2"],
|
||||
"artist" : ["TPE1", "TP1"],
|
||||
"album" : ["TALB", "TAL"],
|
||||
"year" : ["TYER", "TYE"],
|
||||
"comment" : ["COMM", "COM"],
|
||||
"track" : ["TRCK", "TRK"],
|
||||
"genre" : ["TCON", "TCO"],
|
||||
"picture" : ["APIC", "PIC"],
|
||||
"lyrics" : ["USLT", "ULT"]
|
||||
};
|
||||
var _defaultShortcuts = ["title", "artist", "album", "track"];
|
||||
|
||||
var getTagsFromShortcuts = function(shortcuts) {
|
||||
var tags = [];
|
||||
for( var i = 0, shortcut; shortcut = shortcuts[i]; i++ ) {
|
||||
tags = tags.concat(_shortcuts[shortcut]||[shortcut]);
|
||||
}
|
||||
return tags;
|
||||
};
|
||||
var getFrameData = function( frames, ids ) {
|
||||
if( typeof ids == 'string' ) { ids = [ids]; }
|
||||
|
||||
for( var i = 0, id; id = ids[i]; i++ ) {
|
||||
if( id in frames ) { return frames[id].data; }
|
||||
}
|
||||
};
|
||||
var readFrames = function (offset, end, data, id3header, tags) {
|
||||
var frames = {};
|
||||
var frameDataSize;
|
||||
var major = id3header["major"];
|
||||
|
||||
tags = getTagsFromShortcuts(tags || _defaultShortcuts);
|
||||
|
||||
while( offset < end ) {
|
||||
var readFrameFunc = null;
|
||||
var frameData = data;
|
||||
var frameDataOffset = offset;
|
||||
var flags = null;
|
||||
|
||||
switch( major ) {
|
||||
case 2:
|
||||
var frameID = getStringAt(frameData, frameDataOffset, 3);
|
||||
var frameSize = getInteger24At(frameData, frameDataOffset+3, true);
|
||||
var frameHeaderSize = 6;
|
||||
break;
|
||||
|
||||
case 3:
|
||||
var frameID = getStringAt(frameData, frameDataOffset, 4);
|
||||
var frameSize = getLongAt(frameData, frameDataOffset+4, true);
|
||||
var frameHeaderSize = 10;
|
||||
break;
|
||||
|
||||
case 4:
|
||||
var frameID = getStringAt(frameData, frameDataOffset, 4);
|
||||
var frameSize = readSynchsafeInteger32At(frameDataOffset+4, frameData);
|
||||
var frameHeaderSize = 10;
|
||||
break;
|
||||
}
|
||||
// if last frame GTFO
|
||||
if( frameID == "" ) { break; }
|
||||
|
||||
// advance data offset to the next frame data
|
||||
offset += frameHeaderSize + frameSize;
|
||||
// skip unwanted tags
|
||||
if( tags.indexOf( frameID ) < 0 ) { continue; }
|
||||
|
||||
// read frame message and format flags
|
||||
if( major > 2 )
|
||||
{
|
||||
flags = readFrameFlags(frameData, frameDataOffset+8);
|
||||
}
|
||||
|
||||
frameDataOffset += frameHeaderSize;
|
||||
|
||||
// the first 4 bytes are the real data size
|
||||
// (after unsynchronisation && encryption)
|
||||
if( flags && flags.format.data_length_indicator )
|
||||
{
|
||||
frameDataSize = readSynchsafeInteger32At(frameDataOffset, frameData);
|
||||
frameDataOffset += 4;
|
||||
frameSize -= 4;
|
||||
}
|
||||
|
||||
// TODO: support unsynchronisation
|
||||
if( flags && flags.format.unsynchronisation )
|
||||
{
|
||||
//frameData = removeUnsynchronisation(frameData, frameSize);
|
||||
continue;
|
||||
}
|
||||
|
||||
// find frame parsing function
|
||||
|
||||
if( frameID in ID3v2.readFrameData ) {
|
||||
readFrameFunc = ID3v2.readFrameData[frameID];
|
||||
} else if( frameID[0] == "T" ) {
|
||||
readFrameFunc = ID3v2.readFrameData["T*"];
|
||||
}
|
||||
|
||||
var parsedData = readFrameFunc ? readFrameFunc(frameDataOffset, frameSize, frameData, flags) : undefined;
|
||||
var desc = frameID in ID3v2.frames ? ID3v2.frames[frameID] : 'Unknown';
|
||||
|
||||
var frame = {
|
||||
id : frameID,
|
||||
size : frameSize,
|
||||
description : desc,
|
||||
data : parsedData
|
||||
};
|
||||
|
||||
if( frameID in frames ) {
|
||||
if( frames[frameID].id ) {
|
||||
frames[frameID] = [frames[frameID]];
|
||||
}
|
||||
frames[frameID].push(frame);
|
||||
} else {
|
||||
frames[frameID] = frame;
|
||||
}
|
||||
}
|
||||
|
||||
return frames;
|
||||
};
|
||||
|
||||
function getTextEncoding( bite ) {
|
||||
var charset;
|
||||
switch( bite )
|
||||
{
|
||||
case 0x00:
|
||||
charset = 'iso-8859-1';
|
||||
break;
|
||||
|
||||
case 0x01:
|
||||
charset = 'utf-16';
|
||||
break;
|
||||
|
||||
case 0x02:
|
||||
charset = 'utf-16be';
|
||||
break;
|
||||
|
||||
case 0x03:
|
||||
charset = 'utf-8';
|
||||
break;
|
||||
}
|
||||
|
||||
return charset;
|
||||
}
|
||||
|
||||
function getTime( duration )
|
||||
{
|
||||
var duration = duration/1000,
|
||||
seconds = Math.floor( duration ) % 60,
|
||||
minutes = Math.floor( duration/60 ) % 60,
|
||||
hours = Math.floor( duration/3600 );
|
||||
|
||||
return {
|
||||
seconds : seconds,
|
||||
minutes : minutes,
|
||||
hours : hours
|
||||
};
|
||||
}
|
||||
|
||||
function formatTime( time )
|
||||
{
|
||||
var seconds = time.seconds < 10 ? '0'+time.seconds : time.seconds;
|
||||
var minutes = (time.hours > 0 && time.minutes < 10) ? '0'+time.minutes : time.minutes;
|
||||
|
||||
return (time.hours>0?time.hours+':':'') + minutes + ':' + seconds;
|
||||
}
|
||||
|
||||
ID3v2.readFrameData['APIC'] = function readPictureFrame(offset, length, data, flags, v) {
|
||||
v = v || '3';
|
||||
|
||||
var start = offset;
|
||||
var charset = getTextEncoding( data.getUint8(offset) );
|
||||
switch( v ) {
|
||||
case '2':
|
||||
var format = getStringAt(data, offset+1, 3);
|
||||
offset += 4;
|
||||
break;
|
||||
|
||||
case '3':
|
||||
case '4':
|
||||
var format = getStringWithCharsetAt(data, offset+1, length - (offset-start), '');
|
||||
offset += 1 + format.bytesReadCount;
|
||||
break;
|
||||
}
|
||||
var bite = data.getUint8(offset, 1);
|
||||
var type = pictureType[bite];
|
||||
var desc = getStringWithCharsetAt(data, offset+1, length - (offset-start), charset);
|
||||
|
||||
offset += 1 + desc.bytesReadCount;
|
||||
|
||||
return {
|
||||
"format" : format.toString(),
|
||||
"type" : type,
|
||||
"description" : desc.toString(),
|
||||
"data" : getBytesAt(data, offset, (start+length) - offset)
|
||||
};
|
||||
};
|
||||
|
||||
ID3v2.readFrameData['COMM'] = function readCommentsFrame(offset, length, data) {
|
||||
var start = offset;
|
||||
var charset = getTextEncoding( data.getUint8(offset) );
|
||||
var language = getStringAt(data, offset+1, 3 );
|
||||
var shortdesc = getStringWithCharsetAt(data, offset+4, length-4, charset);
|
||||
|
||||
offset += 4 + shortdesc.bytesReadCount;
|
||||
var text = getStringWithCharsetAt(data, offset, (start+length) - offset, charset );
|
||||
|
||||
return {
|
||||
language : language,
|
||||
short_description : shortdesc.toString(),
|
||||
text : text.toString()
|
||||
};
|
||||
};
|
||||
|
||||
ID3v2.readFrameData['COM'] = ID3v2.readFrameData['COMM'];
|
||||
|
||||
ID3v2.readFrameData['PIC'] = function(offset, length, data, flags) {
|
||||
return ID3v2.readFrameData['APIC'](offset, length, data, flags, '2');
|
||||
};
|
||||
|
||||
ID3v2.readFrameData['PCNT'] = function readCounterFrame(offset, length, data) {
|
||||
// FIXME: implement the rest of the spec
|
||||
return data.getInteger32At(offset);
|
||||
};
|
||||
|
||||
ID3v2.readFrameData['CNT'] = ID3v2.readFrameData['PCNT'];
|
||||
|
||||
ID3v2.readFrameData['T*'] = function readTextFrame(offset, length, data) {
|
||||
var charset = getTextEncoding( data.getUint8(offset) );
|
||||
|
||||
return getStringWithCharsetAt(data, offset+1, length-1, charset).toString();
|
||||
};
|
||||
|
||||
ID3v2.readFrameData['TCON'] = function readGenreFrame(offset, length, data) {
|
||||
var text = ID3v2.readFrameData['T*'].apply( this, arguments );
|
||||
return text.replace(/^\(\d+\)/, '');
|
||||
};
|
||||
|
||||
ID3v2.readFrameData['TCO'] = ID3v2.readFrameData['TCON'];
|
||||
|
||||
//ID3v2.readFrameData['TLEN'] = function readLengthFrame(offset, length, data) {
|
||||
// var text = ID3v2.readFrameData['T*'].apply( this, arguments );
|
||||
//
|
||||
// return {
|
||||
// text : text,
|
||||
// parsed : formatTime( getTime(parseInt(text)) )
|
||||
// };
|
||||
//};
|
||||
|
||||
ID3v2.readFrameData['USLT'] = function readLyricsFrame(offset, length, data) {
|
||||
var start = offset;
|
||||
var charset = getTextEncoding( data.getUint8(offset) );
|
||||
var language = getStringAt(data, offset+1, 3 );
|
||||
var descriptor = getStringWithCharsetAt(data, offset+4, length-4, charset );
|
||||
|
||||
offset += 4 + descriptor.bytesReadCount;
|
||||
var lyrics = getStringWithCharsetAt(data, offset, (start+length) - offset, charset );
|
||||
|
||||
return {
|
||||
language : language,
|
||||
descriptor : descriptor.toString(),
|
||||
lyrics : lyrics.toString()
|
||||
};
|
||||
};
|
||||
|
||||
ID3v2.readFrameData['ULT'] = ID3v2.readFrameData['USLT'];
|
||||
|
||||
|
||||
ID3v2.ReadTags = function ( arraybuffer ) {
|
||||
var data = new DataView ( arraybuffer );
|
||||
var offset = 0;
|
||||
|
||||
|
||||
var major = data.getUint8(offset+3);
|
||||
if( major > 4 ) { return {version: '>2.4'}; }
|
||||
var revision = data.getUint8(offset+4);
|
||||
var unsynch = isBitSetAt(data, offset+5, 7);
|
||||
var xheader = isBitSetAt(data, offset+5, 6);
|
||||
var xindicator = isBitSetAt(data, offset+5, 5);
|
||||
var size = readSynchsafeInteger32At(offset+6, data);
|
||||
offset += 10;
|
||||
|
||||
if( xheader ) {
|
||||
var xheadersize = data.getInt32( offset, true ); //data.getLongAt(offset, true);
|
||||
// The 'Extended header size', currently 6 or 10 bytes, excludes itself.
|
||||
offset += xheadersize + 4;
|
||||
}
|
||||
|
||||
var id3 = {
|
||||
"version" : '2.' + major + '.' + revision,
|
||||
"major" : major,
|
||||
"revision" : revision,
|
||||
"flags" : {
|
||||
"unsynchronisation" : unsynch,
|
||||
"extended_header" : xheader,
|
||||
"experimental_indicator" : xindicator
|
||||
},
|
||||
"size" : size
|
||||
};
|
||||
|
||||
var frames = unsynch ? {} : readFrames(offset, size-10, data, id3);
|
||||
// create shortcuts for most common data
|
||||
for( var name in _shortcuts ) if(_shortcuts.hasOwnProperty(name)) {
|
||||
var data = getFrameData( frames, _shortcuts[name] );
|
||||
if( data ) id3[name] = data;
|
||||
}
|
||||
|
||||
for( var frame in frames ) {
|
||||
if( frames.hasOwnProperty(frame) ) {
|
||||
id3[frame] = frames[frame];
|
||||
}
|
||||
}
|
||||
|
||||
return id3;
|
||||
};
|
||||
|
||||
w.ID3v2 = ID3v2;
|
||||
|
||||
|
||||
/// -------
|
||||
|
||||
var ID4 = {};
|
||||
|
||||
ID4.types = {
|
||||
'0' : 'uint8',
|
||||
'1' : 'text',
|
||||
'13' : 'jpeg',
|
||||
'14' : 'png',
|
||||
'21' : 'uint8'
|
||||
};
|
||||
ID4.atom = {
|
||||
'©alb': ['album'],
|
||||
'©art': ['artist'],
|
||||
'©ART': ['artist'],
|
||||
'aART': ['artist'],
|
||||
'©day': ['year'],
|
||||
'©nam': ['title'],
|
||||
'©gen': ['genre'],
|
||||
'trkn': ['track'],
|
||||
'©wrt': ['composer'],
|
||||
'©too': ['encoder'],
|
||||
'cprt': ['copyright'],
|
||||
'covr': ['picture'],
|
||||
'©grp': ['grouping'],
|
||||
'keyw': ['keyword'],
|
||||
'©lyr': ['lyrics'],
|
||||
'©cmt': ['comment'],
|
||||
'tmpo': ['tempo'],
|
||||
'cpil': ['compilation'],
|
||||
'disk': ['disc']
|
||||
};
|
||||
|
||||
ID4.loadData = function(arraybuffer, callback) {
|
||||
var data = new DataView ( arraybuffer );
|
||||
// load the header of the first block
|
||||
loadAtom(data, 0, data.byteLength, callback);
|
||||
};
|
||||
|
||||
/**
|
||||
* Make sure that the [offset, offset+7] bytes (the block header) are
|
||||
* already loaded before calling this function.
|
||||
*/
|
||||
function loadAtom(data, offset, length, callback) {
|
||||
// 8 is the size of the atomSize and atomName fields.
|
||||
// When reading the current block we always read 8 more bytes in order
|
||||
// to also read the header of the next block.
|
||||
var atomSize = getLongAt(data, offset, true);
|
||||
if (atomSize == 0) return callback();
|
||||
var atomName = getStringAt(data, offset + 4, 4);
|
||||
|
||||
// Container atoms
|
||||
if (['moov', 'udta', 'meta', 'ilst'].indexOf(atomName) > -1)
|
||||
{
|
||||
if (atomName == 'meta') offset += 4; // next_item_id (uint32)
|
||||
// data.loadRange([offset+8, offset+8 + 8], function() {
|
||||
loadAtom(data, offset + 8, atomSize - 8, callback);
|
||||
// });
|
||||
} else {
|
||||
// Value atoms
|
||||
var readAtom = atomName in ID4.atom;
|
||||
// data.loadRange([offset+(readAtom?0:atomSize), offset+atomSize + 8], function() {
|
||||
loadAtom(data, offset+atomSize, length, callback);
|
||||
// });
|
||||
}
|
||||
};
|
||||
|
||||
ID4.ReadTags = function(arraybuffer) {
|
||||
var data = new DataView ( arraybuffer );
|
||||
var tag = {};
|
||||
readAtom(tag, data, 0, data.byteLength);
|
||||
return tag;
|
||||
};
|
||||
|
||||
function readAtom(tag, data, offset, length, indent)
|
||||
{
|
||||
// debugger;
|
||||
|
||||
indent = indent === undefined ? "" : indent + " ";
|
||||
var seek = offset;
|
||||
while (seek < offset + length)
|
||||
{
|
||||
var atomSize = data.getInt32(seek); // getLongAt(data, seek, true);
|
||||
if (atomSize == 0) return;
|
||||
var atomName = getStringAt(data, seek + 4, 4);
|
||||
// Container atoms
|
||||
if (atomName === 'meta')
|
||||
{
|
||||
seek += 4; // next_item_id (uint32)
|
||||
readAtom(tag, data, seek + 8, atomSize - 8, indent);
|
||||
return;
|
||||
}
|
||||
if (atomName === 'moov' || atomName === 'udta' || atomName === 'ilst' ) // ['moov', 'udta', 'meta', 'ilst'].indexOf(atomName) > -1)
|
||||
{
|
||||
readAtom(tag, data, seek + 8, atomSize - 8, indent);
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
if (['moov', 'udta', 'meta', 'ilst'].indexOf(atomName) > -1)
|
||||
{
|
||||
if (atomName === 'meta') seek += 4; // next_item_id (uint32)
|
||||
readAtom(tag, data, seek + 8, atomSize - 8, indent);
|
||||
return;
|
||||
}
|
||||
*/
|
||||
|
||||
// Value atoms
|
||||
if (ID4.atom[atomName])
|
||||
{
|
||||
var klass = getInteger24At(data, seek + 16 + 1, true);
|
||||
var atom = ID4.atom[atomName];
|
||||
var type = ID4.types[klass];
|
||||
if (atomName === 'trkn')
|
||||
{
|
||||
tag[atom[0]] = data.getUint8(seek + 16 + 11);
|
||||
tag['count'] = data.getUint8(seek + 16 + 13);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 16: name + size + "data" + size (4 bytes each)
|
||||
// 4: atom version (1 byte) + atom flags (3 bytes)
|
||||
// 4: NULL (usually locale indicator)
|
||||
var dataStart = seek + 16 + 4 + 4;
|
||||
var dataEnd = atomSize - 16 - 4 - 4;
|
||||
var atomData;
|
||||
switch( type ) {
|
||||
case 'text':
|
||||
atomData = getStringWithCharsetAt(data, dataStart, dataEnd, "UTF-8");
|
||||
break;
|
||||
|
||||
case 'uint8':
|
||||
atomData = getShortAt(data, dataStart);
|
||||
break;
|
||||
|
||||
case 'jpeg':
|
||||
case 'png':
|
||||
atomData = {
|
||||
format : "image/" + type,
|
||||
data : getBytesAt(data, dataStart, dataEnd)
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
if (atom[0] === "comment") {
|
||||
tag[atom[0]] = {
|
||||
"text": atomData
|
||||
};
|
||||
} else {
|
||||
tag[atom[0]] = atomData;
|
||||
}
|
||||
}
|
||||
}
|
||||
seek += atomSize;
|
||||
}
|
||||
}
|
||||
|
||||
w.ID4 = ID4;
|
||||
|
||||
})( window, document, PKAudioEditor );
|
||||
@@ -0,0 +1,66 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" manifest="audiomass.appcache">
|
||||
<head>
|
||||
<title>AudioMass - Audio Editor</title>
|
||||
<link href="ico.png" rel="shortcut icon">
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0"/>
|
||||
|
||||
<meta name="description" content="AudioMass is a free full-featured web-based audio & waveform editing tool"/>
|
||||
<meta property="og:image" content="https://audiomass.co/icon.jpg"/>
|
||||
<meta property="og:title" content="AudioMass">
|
||||
<meta property="og:url" content="https://audiomass.co/">
|
||||
<meta property="og:description" content="AudioMass is a free full-featured web-based audio & waveform editing tool">
|
||||
<meta name="keywords" content="AudioMass, WebAudio, WaveForm, audio editing, free audio editing, audio tool, waveform editor, sound editor, open source">
|
||||
<link rel="apple-touch-icon" href="https://audiomass.co/icon-app.png">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-title" content="AudioMass">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:site" content="@pkalogiros">
|
||||
<meta name="twitter:creator" content="@pkalogiros">
|
||||
<meta name="twitter:title" content="AudioMass - Audio Editor">
|
||||
<meta name="twitter:description" content="AudioMass is a free full-featured web-based audio & waveform editing tool">
|
||||
<meta name="twitter:image" content="https://audiomass.co/icon.jpg">
|
||||
|
||||
<link rel="stylesheet" type="text/css" href="main.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
<script src="dist/wavesurfer.js"></script>
|
||||
<script src="dist/plugin/wavesurfer.regions.js"></script>
|
||||
<script src="oneup.js"></script>
|
||||
|
||||
<script src="app.js"></script>
|
||||
<script src="keys.js"></script>
|
||||
<script src="contextmenu.js"></script>
|
||||
<script src="ui-fx.js"></script>
|
||||
<script src="ui.js"></script>
|
||||
<script src="modal.js"></script>
|
||||
<script src="state.js"></script>
|
||||
<script src="engine.js"></script>
|
||||
<script src="actions.js"></script>
|
||||
<script src="drag.js"></script>
|
||||
<script src="recorder.js"></script>
|
||||
|
||||
<script src="welcome.js"></script>
|
||||
<script src="fx-pg-eq.js"></script>
|
||||
<script src="fx-auto.js"></script>
|
||||
<script src="local.js"></script>
|
||||
<script src="id3.js"></script>
|
||||
<script src="lzma.js"></script>
|
||||
|
||||
|
||||
<!--
|
||||
<script src="multitrack.js"></script>
|
||||
<script src="multitrack-ui.js"></script>
|
||||
-->
|
||||
<script>
|
||||
var editor = PKAudioEditor.init ('app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,72 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>AudioMass - Audio Editor</title>
|
||||
<link href="ico.png" rel="shortcut icon">
|
||||
<meta charset="utf-8" />
|
||||
<link rel="manifest" href="manifest.json">
|
||||
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no">
|
||||
|
||||
<meta name="description" content="AudioMass is a free full-featured web-based audio & waveform editing tool"/>
|
||||
<meta property="og:image" content="https://audiomass.co/icon.jpg"/>
|
||||
<meta property="og:title" content="AudioMass">
|
||||
<meta property="og:url" content="https://audiomass.co/">
|
||||
<meta property="og:description" content="AudioMass is a free full-featured web-based audio & waveform editing tool">
|
||||
<meta name="keywords" content="AudioMass, WebAudio, WaveForm, audio editing, free audio editing, audio tool, waveform editor, sound editor, open source">
|
||||
<link rel="apple-touch-icon" href="https://audiomass.co/icon-app.png">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-title" content="AudioMass">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:site" content="@pkalogiros">
|
||||
<meta name="twitter:creator" content="@pkalogiros">
|
||||
<meta name="twitter:title" content="AudioMass - Audio Editor">
|
||||
<meta name="twitter:description" content="AudioMass is a free full-featured web-based audio & waveform editing tool">
|
||||
<meta name="twitter:image" content="https://audiomass.co/icon.jpg">
|
||||
|
||||
|
||||
<link rel="stylesheet" type="text/css" href="main.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
<script src="dist/wavesurfer.js"></script>
|
||||
<script src="dist/plugin/wavesurfer.regions.js"></script>
|
||||
<script src="oneup.js"></script>
|
||||
|
||||
<script src="app.js"></script>
|
||||
<script src="keys.js"></script>
|
||||
<script src="contextmenu.js"></script>
|
||||
<script src="ui-fx.js"></script>
|
||||
<script src="ui.js"></script>
|
||||
<script src="modal.js"></script>
|
||||
<script src="state.js"></script>
|
||||
<script src="engine.js"></script>
|
||||
<script src="actions.js"></script>
|
||||
<script src="drag.js"></script>
|
||||
<script src="recorder.js"></script>
|
||||
|
||||
<script src="welcome.js"></script>
|
||||
<script src="fx-pg-eq.js"></script>
|
||||
<script src="fx-auto.js"></script>
|
||||
<script src="local.js"></script>
|
||||
<script src="id3.js"></script>
|
||||
<script src="lzma.js"></script>
|
||||
<!--
|
||||
<script src="multitrack.js"></script>
|
||||
<script src="multitrack-ui.js"></script>
|
||||
-->
|
||||
<script>
|
||||
var editor = PKAudioEditor.init ('app');
|
||||
if ('serviceWorker' in navigator) {
|
||||
try {
|
||||
// navigator.serviceWorker.register( 'sw.js' );
|
||||
} catch ( error ) {}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,89 @@
|
||||
(function( w, d, PKAE ) {
|
||||
'use strict';
|
||||
|
||||
function KeyHandler () {
|
||||
var q = this;
|
||||
|
||||
q.keyMap = {}; // holds a map of all the active keys
|
||||
q.callbacks = {}; // callbacks for when a key combintation becomes active
|
||||
q.singleCallbacks = {}; // callbacks to the 'keypress' event - not required
|
||||
|
||||
q.addCallback = function (callback_name, callback_function, keys) {
|
||||
q.callbacks[ callback_name ] = {
|
||||
keys : keys,
|
||||
callback : callback_function
|
||||
};
|
||||
};
|
||||
q.addSingleCallback = function (callback_name, callback_function, key) {
|
||||
q.singleCallbacks[ callback_name ] = {
|
||||
key : key,
|
||||
callback : callback_function
|
||||
};
|
||||
};
|
||||
q.removeCallback = function ( callback_name ) {
|
||||
q.callbacks[ callback_name ] = null;
|
||||
};
|
||||
|
||||
d.addEventListener ('keydown', function ( e ) {
|
||||
var keyCode = e.keyCode;
|
||||
|
||||
q.keyDown (keyCode, e);
|
||||
});
|
||||
|
||||
q.keyDown = function (keyCode, e ) {
|
||||
q.keyMap[keyCode] = 1;
|
||||
|
||||
for (var key in q.callbacks) {
|
||||
var group = q.callbacks[key];
|
||||
if (!group) continue;
|
||||
|
||||
var l = group.keys.length;
|
||||
var all_ok = true;
|
||||
while (l-- > 0) {
|
||||
if (!q.keyMap[group.keys[l]])
|
||||
{
|
||||
all_ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
all_ok && group.callback && group.callback ( keyCode, q.keyMap, e );
|
||||
}
|
||||
};
|
||||
|
||||
q.keyUp = function ( keyCode ) {
|
||||
q.keyMap[keyCode] = 0;
|
||||
};
|
||||
|
||||
q.keyPress = function ( keyCode, e ) {
|
||||
for (var key in q.singleCallbacks) {
|
||||
var group = q.singleCallbacks[key];
|
||||
if (!group) continue;
|
||||
|
||||
if (group.key === keyCode)
|
||||
group.callback && group.callback ( e );
|
||||
}
|
||||
};
|
||||
|
||||
d.addEventListener ('keyup', function ( e ) {
|
||||
var keyCode = e.keyCode;
|
||||
q.keyUp (keyCode);
|
||||
});
|
||||
|
||||
d.addEventListener ('keypress', function ( e ) {
|
||||
var keyCode = e.keyCode;
|
||||
|
||||
q.keyPress (keyCode, e);
|
||||
});
|
||||
|
||||
w.addEventListener ('blur', function ( e ) {
|
||||
q.keyMap = {};
|
||||
}, false);
|
||||
d.addEventListener ('contextmenu', function( e ) {
|
||||
e.preventDefault();
|
||||
}, false);
|
||||
};
|
||||
|
||||
PKAE._deps.keyhandler = KeyHandler;
|
||||
|
||||
})( window, document, PKAudioEditor );
|
||||
@@ -0,0 +1,262 @@
|
||||
(function ( w, d, PKAE ) {
|
||||
'use strict';
|
||||
|
||||
var db;
|
||||
var db_name = 'audiomass';
|
||||
var db_version = 1;
|
||||
var db_ready = false;
|
||||
|
||||
var compressors = {
|
||||
'l4z' : {
|
||||
ready: false,
|
||||
loading: false,
|
||||
compress: null,
|
||||
decompress: null,
|
||||
init : function ( callback ) {
|
||||
|
||||
var q = this;
|
||||
q.loading = true;
|
||||
|
||||
var lz4BlockWASM;
|
||||
|
||||
lz4BlockCodec.createInstance('wasm').then(instance => {
|
||||
lz4BlockWASM = instance;
|
||||
|
||||
q.ready = true;
|
||||
q.loading = false;
|
||||
q.compress = function( input, offset ) {
|
||||
if (!lz4BlockWASM) {
|
||||
if (input instanceof ArrayBuffer)
|
||||
return new Uint8Array(input);
|
||||
else
|
||||
return input;
|
||||
}
|
||||
|
||||
return lz4BlockWASM.encodeBlock(input, 0);
|
||||
};
|
||||
q.decompress = function( input, offset, size ) {
|
||||
if (!lz4BlockWASM) {
|
||||
if (input instanceof ArrayBuffer)
|
||||
return new Uint8Array(input);
|
||||
else
|
||||
return input;
|
||||
}
|
||||
|
||||
return lz4BlockWASM.decodeBlock(input, 0, size);
|
||||
};
|
||||
|
||||
callback && callback ();
|
||||
});
|
||||
// ---
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var compression = 'l4z';
|
||||
|
||||
function SaveLocal ( app ) {
|
||||
var q = this;
|
||||
q.on = false;
|
||||
|
||||
this.Init = function ( callback ) {
|
||||
if (q.on) {
|
||||
callback && callback ();
|
||||
return ;
|
||||
}
|
||||
|
||||
if (!window.indexedDB) {
|
||||
callback && callback ('err');
|
||||
return ;
|
||||
}
|
||||
|
||||
var request = indexedDB.open (db_name, db_version);
|
||||
|
||||
request.onerror = function(e) {
|
||||
callback && callback ('err');
|
||||
// console.error('Unable to open database.');
|
||||
};
|
||||
|
||||
request.onupgradeneeded = function(e) {
|
||||
var db = e.target.result;
|
||||
db.createObjectStore('sessions', {keyPath:'id'});
|
||||
};
|
||||
|
||||
request.onsuccess = function(e) {
|
||||
db = e.target.result;
|
||||
|
||||
db.onerror = function( e ) {
|
||||
console.log( e );
|
||||
};
|
||||
|
||||
setTimeout(function() {
|
||||
db_ready = true;
|
||||
q.on = true;
|
||||
|
||||
callback && callback ();
|
||||
app.fireEvent ('DidOpenDB', q);
|
||||
},120);
|
||||
};
|
||||
};
|
||||
|
||||
this.SaveSession = function ( buffer, id, name ) {
|
||||
var q = this;
|
||||
|
||||
var comp = compressors[ compression ];
|
||||
if (!comp.loading && !comp.ready) {
|
||||
comp.init (function() {
|
||||
q.SaveSession (buffer, id, name);
|
||||
});
|
||||
|
||||
return ;
|
||||
}
|
||||
|
||||
var chans = buffer.numberOfChannels;
|
||||
var arr_buffs = [];
|
||||
var arr_buffs2 = [];
|
||||
var arr;
|
||||
var tmp;
|
||||
|
||||
var sample_rate = buffer.sampleRate;
|
||||
|
||||
for (var i = 0; i < chans; ++i) {
|
||||
arr = buffer.getChannelData ( i );
|
||||
|
||||
arr_buffs2.push (arr.buffer.byteLength);
|
||||
tmp = comp.compress ( arr.buffer, 0);
|
||||
arr_buffs.push ( tmp.buffer.slice (tmp.byteOffset, tmp.byteLength + tmp.byteOffset));
|
||||
}
|
||||
|
||||
tmp = null;
|
||||
|
||||
var ob = {
|
||||
id : id,
|
||||
name: name,
|
||||
created: new Date().getTime(),
|
||||
data: arr_buffs,
|
||||
data2: arr_buffs2,
|
||||
durr: buffer.duration.toFixed(3)/1,
|
||||
chans: chans,
|
||||
comp: compression,
|
||||
thumb: PKAudioEditor.engine.GetWave (buffer),
|
||||
samplerate: sample_rate
|
||||
};
|
||||
|
||||
var trans = db.transaction(['sessions'], 'readwrite');
|
||||
var addReq = trans.objectStore('sessions').add(ob);
|
||||
|
||||
addReq.onerror = function(e) {
|
||||
app.fireEvent ('ErrorDB', e);
|
||||
|
||||
console.log('error storing data');
|
||||
console.error(e);
|
||||
};
|
||||
|
||||
trans.oncomplete = function ( e ) {
|
||||
app.fireEvent ('DidStoreDB', ob, e );
|
||||
// console.log( 'data stored', id, e );
|
||||
};
|
||||
};
|
||||
|
||||
this.GetSession = function ( id, callback ) {
|
||||
var trans = db.transaction(['sessions'], 'readonly');
|
||||
//hard coded id
|
||||
var req = trans.objectStore('sessions').get(id);
|
||||
|
||||
req.onsuccess = function(e) {
|
||||
// console.log( e.target.result );
|
||||
|
||||
|
||||
var record = e.target.result;
|
||||
|
||||
if (record && record.comp)
|
||||
{
|
||||
var comp = compressors[ compression ];
|
||||
if (!comp.loading && !comp.ready) {
|
||||
comp.init (function() {
|
||||
|
||||
var data_arr = [];
|
||||
var tmp = null;
|
||||
|
||||
for (var i = 0; i < record.data.length; ++i) {
|
||||
tmp = comp.decompress (record.data[i], 0, record.data2[i]);
|
||||
data_arr.push (
|
||||
tmp.buffer.slice (tmp.byteOffset, tmp.byteLength + tmp.byteOffset)
|
||||
);
|
||||
}
|
||||
|
||||
tmp = null;
|
||||
record.data = data_arr;
|
||||
|
||||
callback && callback ( record );
|
||||
});
|
||||
|
||||
return ;
|
||||
}
|
||||
|
||||
var data_arr = [];
|
||||
var tmp = null;
|
||||
|
||||
for (var i = 0; i < record.data.length; ++i) {
|
||||
tmp = comp.decompress (record.data[i], 0, record.data2[i]);
|
||||
data_arr.push (
|
||||
tmp.buffer.slice (tmp.byteOffset, tmp.byteLength + tmp.byteOffset)
|
||||
);
|
||||
}
|
||||
|
||||
tmp = null;
|
||||
record.data = data_arr;
|
||||
}
|
||||
|
||||
callback && callback ( record );
|
||||
};
|
||||
};
|
||||
|
||||
this.DelSession = function ( id, callback ) {
|
||||
var trans = db.transaction(['sessions'], 'readwrite');
|
||||
|
||||
var req = trans.objectStore('sessions').delete (id);
|
||||
req.onsuccess = function (e) {
|
||||
callback && callback ( id );
|
||||
};
|
||||
};
|
||||
|
||||
this.ListSessions = function ( callback ) {
|
||||
var trans = db.transaction(['sessions'], 'readonly');
|
||||
var object_store = trans.objectStore('sessions');
|
||||
var req = object_store.openCursor();
|
||||
var ret = [];
|
||||
|
||||
req.onerror = function(event) {
|
||||
console.err("error fetching data");
|
||||
};
|
||||
req.onsuccess = function(event) {
|
||||
var cursor = event.target.result;
|
||||
if (cursor) {
|
||||
var key = cursor.primaryKey;
|
||||
var value = cursor.value;
|
||||
|
||||
ret.push (value);
|
||||
cursor.continue();
|
||||
}
|
||||
else {
|
||||
var rr = ret.sort(function compare( a, b ) {
|
||||
if ( a.created > b.created ){
|
||||
return -1;
|
||||
}
|
||||
if ( a.created < b.created ){
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
callback && callback (rr);
|
||||
// no more results
|
||||
}
|
||||
};
|
||||
};
|
||||
// ---
|
||||
};
|
||||
|
||||
PKAudioEditor._deps.fls = SaveLocal;
|
||||
|
||||
})( window, document, PKAudioEditor );
|
||||
@@ -0,0 +1,194 @@
|
||||
/*******************************************************************************
|
||||
|
||||
lz4-block-codec-wasm.js
|
||||
A javascript wrapper around a WebAssembly implementation of
|
||||
LZ4 block format codec.
|
||||
Copyright (C) 2018 Raymond Hill
|
||||
|
||||
BSD-2-Clause License (http://www.opensource.org/licenses/bsd-license.php)
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above
|
||||
copyright notice, this list of conditions and the following disclaimer
|
||||
in the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
Home: https://github.com/gorhill/lz4-wasm
|
||||
|
||||
I used the same license as the one picked by creator of LZ4 out of respect
|
||||
for his creation, see https://lz4.github.io/lz4/
|
||||
|
||||
*/
|
||||
|
||||
/* global WebAssembly */
|
||||
|
||||
'use strict';
|
||||
|
||||
/******************************************************************************/
|
||||
|
||||
(function(context) { // >>>> Start of private namespace
|
||||
|
||||
/******************************************************************************/
|
||||
|
||||
let wd = (function() {
|
||||
let url = document.currentScript.src;
|
||||
let match = /[^\/]+$/.exec(url);
|
||||
return match !== null ?
|
||||
url.slice(0, match.index) :
|
||||
'';
|
||||
})();
|
||||
|
||||
let growMemoryTo = function(wasmInstance, byteLength) {
|
||||
let lz4api = wasmInstance.exports;
|
||||
let neededByteLength = lz4api.getLinearMemoryOffset() + byteLength;
|
||||
let pageCountBefore = lz4api.memory.buffer.byteLength >>> 16;
|
||||
let pageCountAfter = (neededByteLength + 65535) >>> 16;
|
||||
if ( pageCountAfter > pageCountBefore ) {
|
||||
lz4api.memory.grow(pageCountAfter - pageCountBefore);
|
||||
}
|
||||
return lz4api.memory.buffer;
|
||||
};
|
||||
|
||||
let encodeBlock = function(wasmInstance, inputArray, outputOffset) {
|
||||
let lz4api = wasmInstance.exports;
|
||||
let mem0 = lz4api.getLinearMemoryOffset();
|
||||
let hashTableSize = 65536 * 4;
|
||||
let inputSize = inputArray.byteLength;
|
||||
if ( inputSize >= 0x7E000000 ) { throw new RangeError(); }
|
||||
let memSize =
|
||||
hashTableSize +
|
||||
inputSize +
|
||||
outputOffset + lz4api.lz4BlockEncodeBound(inputSize);
|
||||
let memBuffer = growMemoryTo(wasmInstance, memSize);
|
||||
let hashTable = new Int32Array(memBuffer, mem0, 65536);
|
||||
hashTable.fill(-65536, 0, 65536);
|
||||
let inputMem = new Uint8Array(memBuffer, mem0 + hashTableSize, inputSize);
|
||||
inputMem.set(inputArray);
|
||||
let outputSize = lz4api.lz4BlockEncode(
|
||||
mem0 + hashTableSize,
|
||||
inputSize,
|
||||
mem0 + hashTableSize + inputSize + outputOffset
|
||||
);
|
||||
if ( outputSize === 0 ) { return; }
|
||||
let outputArray = new Uint8Array(
|
||||
memBuffer,
|
||||
mem0 + hashTableSize + inputSize,
|
||||
outputOffset + outputSize
|
||||
);
|
||||
return outputArray;
|
||||
};
|
||||
|
||||
let decodeBlock = function(wasmInstance, inputArray, inputOffset, outputSize) {
|
||||
let inputSize = inputArray.byteLength;
|
||||
let lz4api = wasmInstance.exports;
|
||||
let mem0 = lz4api.getLinearMemoryOffset();
|
||||
let memSize = inputSize + outputSize;
|
||||
let memBuffer = growMemoryTo(wasmInstance, memSize);
|
||||
let inputArea = new Uint8Array(memBuffer, mem0, inputSize);
|
||||
inputArea.set(inputArray);
|
||||
outputSize = lz4api.lz4BlockDecode(
|
||||
mem0 + inputOffset,
|
||||
inputSize - inputOffset,
|
||||
mem0 + inputSize
|
||||
);
|
||||
if ( outputSize === 0 ) { return; }
|
||||
return new Uint8Array(memBuffer, mem0 + inputSize, outputSize);
|
||||
};
|
||||
|
||||
/******************************************************************************/
|
||||
|
||||
context.LZ4BlockWASM = function() {
|
||||
this.lz4wasmInstance = undefined;
|
||||
};
|
||||
|
||||
context.LZ4BlockWASM.prototype = {
|
||||
flavor: 'wasm',
|
||||
|
||||
init: function() {
|
||||
if (
|
||||
typeof WebAssembly !== 'object' ||
|
||||
typeof WebAssembly.instantiateStreaming !== 'function'
|
||||
) {
|
||||
this.lz4wasmInstance = null;
|
||||
}
|
||||
if ( this.lz4wasmInstance === null ) {
|
||||
return Promise.reject();
|
||||
}
|
||||
if ( this.lz4wasmInstance instanceof WebAssembly.Instance ) {
|
||||
return Promise.resolve(this.lz4wasmInstance);
|
||||
}
|
||||
if ( this.lz4wasmInstance === undefined ) {
|
||||
this.lz4wasmInstance = WebAssembly.instantiateStreaming(
|
||||
fetch(wd + 'lz4-block-codec.wasm', { mode: 'same-origin' })
|
||||
).then(result => {
|
||||
this.lz4wasmInstance = undefined;
|
||||
this.lz4wasmInstance = result && result.instance || null;
|
||||
if ( this.lz4wasmInstance !== null ) { return this; }
|
||||
return null;
|
||||
});
|
||||
this.lz4wasmInstance.catch(( ) => {
|
||||
this.lz4wasmInstance = null;
|
||||
return null;
|
||||
});
|
||||
}
|
||||
return this.lz4wasmInstance;
|
||||
},
|
||||
|
||||
reset: function() {
|
||||
this.lz4wasmInstance = undefined;
|
||||
},
|
||||
|
||||
bytesInUse: function() {
|
||||
return this.lz4wasmInstance instanceof WebAssembly.Instance ?
|
||||
this.lz4wasmInstance.exports.memory.buffer.byteLength :
|
||||
0;
|
||||
},
|
||||
|
||||
encodeBlock: function(input, outputOffset) {
|
||||
if ( this.lz4wasmInstance instanceof WebAssembly.Instance === false ) {
|
||||
throw new Error('LZ4BlockWASM: not initialized');
|
||||
}
|
||||
if ( input instanceof ArrayBuffer ) {
|
||||
input = new Uint8Array(input);
|
||||
} else if ( input instanceof Uint8Array === false ) {
|
||||
throw new TypeError();
|
||||
}
|
||||
return encodeBlock(this.lz4wasmInstance, input, outputOffset);
|
||||
},
|
||||
|
||||
decodeBlock: function(input, inputOffset, outputSize) {
|
||||
if ( this.lz4wasmInstance instanceof WebAssembly.Instance === false ) {
|
||||
throw new Error('LZ4BlockWASM: not initialized');
|
||||
}
|
||||
if ( input instanceof ArrayBuffer ) {
|
||||
input = new Uint8Array(input);
|
||||
} else if ( input instanceof Uint8Array === false ) {
|
||||
throw new TypeError();
|
||||
}
|
||||
return decodeBlock(this.lz4wasmInstance, input, inputOffset, outputSize);
|
||||
}
|
||||
};
|
||||
|
||||
/******************************************************************************/
|
||||
|
||||
})(this || self); // <<<< End of private namespace
|
||||
|
||||
/******************************************************************************/
|
||||
@@ -0,0 +1,169 @@
|
||||
/*******************************************************************************
|
||||
|
||||
lz4-block-codec-any.js
|
||||
A wrapper to instanciate a wasm- and/or js-based LZ4 block
|
||||
encoder/decoder.
|
||||
Copyright (C) 2018 Raymond Hill
|
||||
|
||||
BSD-2-Clause License (http://www.opensource.org/licenses/bsd-license.php)
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above
|
||||
copyright notice, this list of conditions and the following disclaimer
|
||||
in the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
Home: https://github.com/gorhill/lz4-wasm
|
||||
|
||||
I used the same license as the one picked by creator of LZ4 out of respect
|
||||
for his creation, see https://lz4.github.io/lz4/
|
||||
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/******************************************************************************/
|
||||
|
||||
(function(context) { // >>>> Start of private namespace
|
||||
|
||||
/******************************************************************************/
|
||||
|
||||
let wd = (function() {
|
||||
let url = document.currentScript.src;
|
||||
let match = /[^\/]+$/.exec(url);
|
||||
return match !== null ?
|
||||
url.slice(0, match.index) :
|
||||
'';
|
||||
})();
|
||||
|
||||
let removeScript = function(script) {
|
||||
if ( !script ) { return; }
|
||||
if ( script.parentNode === null ) { return; }
|
||||
script.parentNode.removeChild(script);
|
||||
};
|
||||
|
||||
let createInstanceWASM = function() {
|
||||
if ( context.LZ4BlockWASM instanceof Function ) {
|
||||
let instance = new context.LZ4BlockWASM();
|
||||
return instance.init().then(( ) => { return instance; });
|
||||
}
|
||||
if ( context.LZ4BlockWASM === null ) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
let script = document.createElement('script');
|
||||
script.src = wd + 'lz4-block-codec-wasm.js';
|
||||
script.addEventListener('load', ( ) => {
|
||||
if ( context.LZ4BlockWASM instanceof Function === false ) {
|
||||
context.LZ4BlockWASM = null;
|
||||
context.LZ4BlockWASM = undefined;
|
||||
resolve(null);
|
||||
} else {
|
||||
let instance = new context.LZ4BlockWASM();
|
||||
instance.init()
|
||||
.then(( ) => {
|
||||
resolve(instance);
|
||||
})
|
||||
.catch(error => {
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
});
|
||||
script.addEventListener('error', ( ) => {
|
||||
context.LZ4BlockWASM = null;
|
||||
resolve(null);
|
||||
});
|
||||
document.head.appendChild(script);
|
||||
removeScript(script);
|
||||
});
|
||||
};
|
||||
|
||||
let createInstanceJS = function() {
|
||||
if ( context.LZ4BlockJS instanceof Function ) {
|
||||
let instance = new context.LZ4BlockJS();
|
||||
return instance.init().then(( ) => { return instance; });
|
||||
}
|
||||
if ( context.LZ4BlockJS === null ) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
let script = document.createElement('script');
|
||||
script.src = wd + 'lz4-block-codec-js.js';
|
||||
script.addEventListener('load', ( ) => {
|
||||
if ( context.LZ4BlockJS instanceof Function === false ) {
|
||||
context.LZ4BlockJS = null;
|
||||
resolve(null);
|
||||
} else {
|
||||
let instance = new context.LZ4BlockJS();
|
||||
instance.init()
|
||||
.then(( ) => {
|
||||
resolve(instance);
|
||||
})
|
||||
.catch(error => {
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
});
|
||||
script.addEventListener('error', ( ) => {
|
||||
context.LZ4BlockJS = null;
|
||||
resolve(null);
|
||||
});
|
||||
document.head.appendChild(script);
|
||||
removeScript(script);
|
||||
});
|
||||
};
|
||||
|
||||
/******************************************************************************/
|
||||
|
||||
context.lz4BlockCodec = {
|
||||
createInstance: function(flavor) {
|
||||
let instantiator;
|
||||
if ( flavor === 'wasm' ) {
|
||||
instantiator = createInstanceWASM;
|
||||
} else if ( flavor === 'js' ) {
|
||||
instantiator = createInstanceJS;
|
||||
} else {
|
||||
instantiator = createInstanceWASM || createInstanceJS;
|
||||
}
|
||||
return (instantiator)()
|
||||
.then(instance => {
|
||||
if ( instance ) { return instance; }
|
||||
if ( flavor === undefined ) {
|
||||
return createInstanceJS();
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.catch(( ) => {
|
||||
if ( flavor === undefined ) {
|
||||
return createInstanceJS();
|
||||
}
|
||||
return null;
|
||||
});
|
||||
},
|
||||
reset: function() {
|
||||
context.LZ4BlockWASM = undefined;
|
||||
context.LZ4BlockJS = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/******************************************************************************/
|
||||
|
||||
})(this || self); // <<<< End of private namespace
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "Audiomass - Audio Editor",
|
||||
"short_name": "Audiomass - Audio Editor",
|
||||
"start_url": ".",
|
||||
"scope": ".",
|
||||
"display": "standalone",
|
||||
"icons": [
|
||||
{
|
||||
"src": "icon.png",
|
||||
"type": "image/png",
|
||||
"sizes": "144x144"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
(function ( w, d ) {
|
||||
|
||||
var _id = 0;
|
||||
|
||||
function PKSimpleModal ( config ) {
|
||||
var q = this;
|
||||
|
||||
this.id = config.id ? config.id : (++_id);
|
||||
|
||||
var el = d.createElement ('div');
|
||||
this.els = {
|
||||
toolbar:[],
|
||||
bottom:[]
|
||||
};
|
||||
el.className = 'pk_modal ' + (config.clss ? config.clss : '');
|
||||
|
||||
q.el = el;
|
||||
|
||||
// backdrop
|
||||
var el_back = d.createElement ('div');
|
||||
el_back.className = 'pk_modal_back';
|
||||
this.el_back = el_back;
|
||||
|
||||
// var centerer
|
||||
var el_cont = d.createElement ('div');
|
||||
el_cont.className = 'pk_modal_cnt';
|
||||
this.el_cont = el_cont;
|
||||
|
||||
// title
|
||||
var el_title = d.createElement ('div');
|
||||
el_title.className = 'pk_noselect pk_modal_title';
|
||||
el_title.innerHTML = '<span>'+ (config.title || '') +'</span>';
|
||||
el.appendChild ( el_title );
|
||||
this.el_title = el_title;
|
||||
|
||||
// main
|
||||
var el_main = d.createElement ('div');
|
||||
el_main.className = 'pk_modal_main';
|
||||
el.appendChild ( el_main );
|
||||
this.el_body = el_main;
|
||||
|
||||
// bottom buttons
|
||||
var el_bottom = d.createElement ('div');
|
||||
el_bottom.className = 'pk_noselect pk_modal_bottom';
|
||||
// -----------
|
||||
var a_cancel = d.createElement ('a');
|
||||
a_cancel.innerHTML = 'CANCEL';
|
||||
a_cancel.className = 'pk_modal_cancel pk_modal_a_bottom';
|
||||
a_cancel.onclick = function () {
|
||||
q.Destroy ();
|
||||
};
|
||||
el_bottom.appendChild ( a_cancel );
|
||||
|
||||
// check if we need to construct more buttons from the config...
|
||||
if (config.buttons && config.buttons.length > 0)
|
||||
{
|
||||
for (var i = 0; i < config.buttons.length; ++i)
|
||||
{
|
||||
var curr = config.buttons[i];
|
||||
|
||||
if (!curr.title || !curr.callback) continue;
|
||||
var a_bottom = d.createElement ('a');
|
||||
a_bottom.innerHTML = curr.title;
|
||||
a_bottom.className = 'pk_modal_a_bottom ' + (curr.clss ? curr.clss : '');
|
||||
|
||||
if (curr.callback)
|
||||
{
|
||||
(function ( callback ) {
|
||||
a_bottom.onclick = function () {
|
||||
callback ( q );
|
||||
};
|
||||
})( curr.callback );
|
||||
}
|
||||
q.els.bottom.push (a_bottom);
|
||||
el_bottom.appendChild ( a_bottom );
|
||||
}
|
||||
}
|
||||
el.appendChild ( el_bottom );
|
||||
|
||||
// -----
|
||||
if (config.toolbar && config.toolbar.length > 0)
|
||||
{
|
||||
for (var i = 0; i < config.toolbar.length; ++i)
|
||||
{
|
||||
var curr = config.toolbar[i];
|
||||
if (!curr.title || !curr.callback) continue;
|
||||
var a_link = d.createElement ('a');
|
||||
a_link.innerHTML = curr.title;
|
||||
a_link.className = 'pk_modal_a_top ' + (curr.clss ? curr.clss : '');
|
||||
el_title.appendChild ( a_link );
|
||||
|
||||
if (curr.callback)
|
||||
{
|
||||
(function ( callback ) {
|
||||
a_link.onclick = function () {
|
||||
callback ( q, this );
|
||||
};
|
||||
})( curr.callback );
|
||||
}
|
||||
q.els.toolbar.push (a_link);
|
||||
}
|
||||
}
|
||||
|
||||
this.ondestroy = config.ondestroy;
|
||||
if (config.body) q.el_body.innerHTML = config.body;
|
||||
if (config.onpreset) this.onpreset = config.onpreset;
|
||||
if (config.setup) config.setup ( this );
|
||||
};
|
||||
|
||||
PKSimpleModal.prototype.Show = function () {
|
||||
|
||||
this.el_back.appendChild ( this.el_cont );
|
||||
this.el_cont.appendChild ( this.el );
|
||||
|
||||
d.body.appendChild ( this.el_back );
|
||||
|
||||
return (this);
|
||||
};
|
||||
|
||||
PKSimpleModal.prototype.Destroy = function () {
|
||||
|
||||
if (this.ondestroy) {
|
||||
this.ondestroy ( this );
|
||||
this.ondestroy = null;
|
||||
}
|
||||
this.els = null;
|
||||
d.body.removeChild ( this.el_back );
|
||||
};
|
||||
|
||||
|
||||
// Extended modal
|
||||
function PKAudioFXModal ( config, app ) {
|
||||
var toolbar = null;
|
||||
|
||||
if (config.preview)
|
||||
{
|
||||
toolbar = [
|
||||
{
|
||||
title:'ON',
|
||||
clss:'pk_inact',
|
||||
callback: function ( q, el ) {
|
||||
app.fireEvent ('RequestActionFX_TOGGLE');
|
||||
}
|
||||
},
|
||||
{
|
||||
title:'Preview',
|
||||
callback: function ( q ) {
|
||||
config.preview && config.preview ( q );
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
var inner_modal = new PKSimpleModal({
|
||||
id: config.id,
|
||||
title: config.title,
|
||||
clss: config.clss,
|
||||
presets: config.presets,
|
||||
updateFilter: config.updateFilter,
|
||||
ondestroy: function ( q ) {
|
||||
|
||||
app.fireEvent ('DidCloseFX_UI');
|
||||
|
||||
app.stopListeningFor ('DidStartPreview', q._evstart);
|
||||
app.stopListeningFor ('DidStopPreview', q._evstop);
|
||||
app.stopListeningFor ('DidTogglePreview', q._evtoggle);
|
||||
app.stopListeningFor ('DidSetPresets', q._updatePresets);
|
||||
app.stopListeningFor ('RequestActionFX_UPDATE_PREVIEW', q._updpreview);
|
||||
app.stopListeningFor ('RequestSetPresetActive', q._updpreset);
|
||||
|
||||
app.fireEvent ('RequestActionFX_PREVIEW_STOP');
|
||||
|
||||
// if preview remove callback
|
||||
app.ui.KeyHandler.removeCallback ('ksp' + q.id);
|
||||
|
||||
|
||||
config.ondestroy && config.ondestroy ( q );
|
||||
},
|
||||
toolbar: toolbar,
|
||||
buttons: config.buttons,
|
||||
body: config.body,
|
||||
onpreset: config.onpreset,
|
||||
setup:function( q ) {
|
||||
app.fireEvent ('RequestActionFX_TOGGLE', 1);
|
||||
|
||||
var slf = this;
|
||||
app.ui.KeyHandler.addCallback ('ksp' + q.id, function ( key, map ) {
|
||||
if (!app.ui.InteractionHandler.check ('modalfx')) return ;
|
||||
|
||||
var tb = slf.toolbar;
|
||||
if (tb && tb.length > 0)
|
||||
{
|
||||
var k = tb.length;
|
||||
while (k-- > 0) {
|
||||
if (tb[k].title === 'Preview') {
|
||||
tb[k].callback ( q );
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [32]);
|
||||
|
||||
q._evstart = function () {
|
||||
q.els.toolbar[0].classList.remove ('pk_inact');
|
||||
q.els.toolbar[1].classList.add ('pk_act');
|
||||
};
|
||||
q._evstop = function () {
|
||||
q.els.toolbar[0].classList.add ('pk_inact');
|
||||
q.els.toolbar[1].classList.remove ('pk_act');
|
||||
}
|
||||
|
||||
q._evtoggle = function ( val ) {
|
||||
var el = q.els.toolbar[0];
|
||||
if (val) el.innerHTML = 'ON';
|
||||
else el.innerHTML = 'OFF';
|
||||
};
|
||||
|
||||
var stopped_listening = false;
|
||||
q._updpreview = function ( val ) {
|
||||
var sel_opt = q.el_presets.options[q.el_presets.selectedIndex];
|
||||
var btn = q.el.getElementsByClassName('pk_sel_edt')[0];
|
||||
|
||||
if (val === 't')
|
||||
{
|
||||
if (sel_opt && sel_opt.getAttribute('data-custom')) {
|
||||
btn.style.visibility = 'visible';
|
||||
btn.style.opacity = '1';
|
||||
app.stopListeningFor ('RequestActionFX_UPDATE_PREVIEW', q._updpreview);
|
||||
}
|
||||
else
|
||||
{
|
||||
btn.style.visibility = 'hidden';
|
||||
btn.style.opacity = '0';
|
||||
|
||||
app.stopListeningFor ('RequestActionFX_UPDATE_PREVIEW', q._updpreview);
|
||||
|
||||
//if (stopped_listening)
|
||||
//{
|
||||
setTimeout(function (){
|
||||
app.listenFor ('RequestActionFX_UPDATE_PREVIEW', q._updpreview);
|
||||
}, 100);
|
||||
stopped_listening = false;
|
||||
//}
|
||||
}
|
||||
return ;
|
||||
}
|
||||
|
||||
// if (sel_opt && sel_opt.getAttribute('data-custom')) {
|
||||
btn.style.visibility = 'visible';
|
||||
btn.style.opacity = '1';
|
||||
stopped_listening = true;
|
||||
app.stopListeningFor ('RequestActionFX_UPDATE_PREVIEW', q._updpreview);
|
||||
// }
|
||||
};
|
||||
|
||||
q._updpreset = function ( fx_id, preset_id ) {
|
||||
if (fx_id && fx_id !== q.id) {
|
||||
return ;
|
||||
}
|
||||
|
||||
var opts = q.el_presets.getElementsByTagName('option');
|
||||
var ll = opts.length;
|
||||
var curr = null;
|
||||
|
||||
while (ll-- > 0) {
|
||||
curr = opts[ll];
|
||||
|
||||
if (curr.getAttribute('data-custom') === preset_id) {
|
||||
curr.selected = 'selected';
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
q._updatePresets = function ( fx_id, presets ) {
|
||||
if (fx_id && fx_id !== q.id) {
|
||||
return ;
|
||||
}
|
||||
|
||||
var d = document;
|
||||
var sel_presets = q.el.getElementsByClassName ('pk_sel');
|
||||
|
||||
// if presets exist remove them
|
||||
if (sel_presets.length > 0)
|
||||
{
|
||||
sel_presets = sel_presets[0];
|
||||
var opts = sel_presets.getElementsByTagName('option');
|
||||
var ll = opts.length;
|
||||
var curr = null;
|
||||
|
||||
while (ll-- > 0) {
|
||||
curr = opts[ll];
|
||||
|
||||
if (curr.getAttribute('data-custom')) {
|
||||
sel_presets.removeChild( curr );
|
||||
}
|
||||
}
|
||||
|
||||
if (presets.length === 0) return ;
|
||||
|
||||
var opt = d.createElement ('option');
|
||||
// opt.value = '---custom----';
|
||||
opt.setAttribute ('disabled', '1');
|
||||
opt.setAttribute ('data-custom', '1');
|
||||
opt.innerHTML = '----custom-----';
|
||||
sel_presets.appendChild( opt );
|
||||
|
||||
for (var i = 0; i < presets.length; ++i)
|
||||
{
|
||||
var opt = d.createElement ('option');
|
||||
var curr = presets[ i ];
|
||||
opt.value = curr.val;
|
||||
opt.setAttribute ('data-custom', curr.id);
|
||||
opt.innerHTML = curr.name;
|
||||
sel_presets.appendChild( opt );
|
||||
}
|
||||
|
||||
return ;
|
||||
}
|
||||
else
|
||||
{
|
||||
sel_presets = d.createElement ('select');
|
||||
sel_presets.className = 'pk_sel';
|
||||
}
|
||||
|
||||
if (presets.length === 0) return ;
|
||||
|
||||
|
||||
for (var i = -1; i < presets.length; ++i)
|
||||
{
|
||||
var opt = d.createElement ('option');
|
||||
|
||||
if (i === -1)
|
||||
{
|
||||
opt.value = 'null';
|
||||
opt.innerHTML = 'Presets';
|
||||
}
|
||||
else
|
||||
{
|
||||
var curr = presets[ i ];
|
||||
opt.value = curr.val;
|
||||
opt.innerHTML = curr.name;
|
||||
}
|
||||
sel_presets.appendChild( opt );
|
||||
}
|
||||
|
||||
sel_presets.onchange = function () {
|
||||
var val_arr = this.value.split(',');
|
||||
var els = q.el.getElementsByTagName('input');
|
||||
|
||||
q._updpreview ('t');
|
||||
|
||||
if (q.onpreset)
|
||||
{
|
||||
q.onpreset (this.value);
|
||||
return ;
|
||||
}
|
||||
|
||||
var len = els.length;
|
||||
|
||||
for (var i = 0; i < len; ++i) {
|
||||
if (!val_arr[ i ]) break;
|
||||
|
||||
var curr_val = val_arr[ i ].trim ();
|
||||
var curr_input = els[ i ];
|
||||
|
||||
if (curr_val === 'null') continue;
|
||||
|
||||
if (curr_input.type === 'checkbox' || curr_input.type === 'radio')
|
||||
curr_input.checked = curr_val;
|
||||
else
|
||||
{
|
||||
curr_input.value = curr_val;
|
||||
curr_input.oninput && curr_input.oninput.apply (curr_input);
|
||||
}
|
||||
}
|
||||
};
|
||||
var btm = q.el.getElementsByClassName('pk_modal_bottom')[0];
|
||||
|
||||
btm.appendChild ( sel_presets );
|
||||
q.el_presets = sel_presets;
|
||||
|
||||
// now add preset edit button
|
||||
var edit_presets = d.createElement ('a');
|
||||
edit_presets.className = 'pk_sel_edt';
|
||||
edit_presets.innerHTML = '...<span>Save or Modify preset</span>';
|
||||
edit_presets.onclick = function () {
|
||||
app.fireEvent ('RequestSavePreset');
|
||||
};
|
||||
|
||||
btm.appendChild ( edit_presets );
|
||||
app.listenFor ('RequestActionFX_UPDATE_PREVIEW', q._updpreview);
|
||||
app.listenFor ('RequestSetPresetActive', q._updpreset);
|
||||
};
|
||||
|
||||
|
||||
app.listenFor ('DidStartPreview', q._evstart);
|
||||
app.listenFor ('DidStopPreview', q._evstop);
|
||||
app.listenFor ('DidTogglePreview', q._evtoggle);
|
||||
app.fireEvent ('DidOpenFX_UI', q);
|
||||
|
||||
if (config.updateFilter) q.updateFilter = config.updateFilter;
|
||||
|
||||
if (config.presets)
|
||||
{
|
||||
q._updatePresets (null, config.presets);
|
||||
if (config.custom_pres) q._updatePresets (null, config.custom_pres);
|
||||
|
||||
app.listenFor ('DidSetPresets', q._updatePresets);
|
||||
}
|
||||
|
||||
config.setup && config.setup ( q );
|
||||
}
|
||||
});
|
||||
|
||||
return (inner_modal);
|
||||
};
|
||||
|
||||
w.PKSimpleModal = PKSimpleModal;
|
||||
w.PKAudioFXModal = PKAudioFXModal;
|
||||
})( window, document );
|
||||
@@ -0,0 +1,29 @@
|
||||
(function ( w, d ) {
|
||||
|
||||
function OneUp ( _text, _time, _clss ) {
|
||||
var el = d.createElement ('div');
|
||||
var cl = 'pk_oneup pk_noselect';
|
||||
|
||||
el.style.cssText = 'margin-top:20px;opacity:0';
|
||||
if (_clss) cl = cl + ' ' + _clss;
|
||||
|
||||
el.className = cl;
|
||||
el.innerHTML = _text || '';
|
||||
|
||||
d.body.appendChild ( el );
|
||||
setTimeout (function() {
|
||||
el.style.cssText = 'margin-top:0px;opacity:1';
|
||||
|
||||
setTimeout (function() {
|
||||
el.style.cssText = 'margin-top:-20px;opacity:0';
|
||||
|
||||
setTimeout (function() {
|
||||
el.parentNode.removeChild ( el );
|
||||
el = null;
|
||||
}, 330);
|
||||
}, _time || 720);
|
||||
}, 25);
|
||||
}
|
||||
|
||||
w.OneUp = OneUp;
|
||||
})( window, document );
|
||||
|
After Width: | Height: | Size: 64 KiB |
@@ -0,0 +1,223 @@
|
||||
(function ( w, d, PKAE ) {
|
||||
'use strict';
|
||||
|
||||
var PKREC = function ( app ) {
|
||||
var q = this;
|
||||
|
||||
var media_stream_source = null;
|
||||
var audio_stream = null;
|
||||
var audio_context = null;
|
||||
var script_processor = null;
|
||||
|
||||
var buffer_size = 2048 * 2; // * 2 ?
|
||||
var channel_num = 1;
|
||||
var channel_num_out = 1;
|
||||
|
||||
var is_active = false;
|
||||
|
||||
var starting_offset = 0;
|
||||
var ending_offset = 0;
|
||||
|
||||
var sample_rate = 0;
|
||||
|
||||
var temp_buffers = [];
|
||||
var temp_buffer_index = -1;
|
||||
var jumps = 1;
|
||||
|
||||
var end_record_func = null;
|
||||
var start_record_func = null;
|
||||
|
||||
// temp vars
|
||||
var curr_offset = 0;
|
||||
var first_skip = 8; // skip first samples to evade the button's click
|
||||
var fetchBufferFunction = function( ev ) {
|
||||
|
||||
if (first_skip > 0) {
|
||||
--first_skip;
|
||||
return ;
|
||||
}
|
||||
|
||||
curr_offset += ev.inputBuffer.duration * sample_rate;
|
||||
if (ending_offset <= curr_offset)
|
||||
{
|
||||
ending_offset > 0 && q.stop ();
|
||||
return ;
|
||||
}
|
||||
|
||||
var float_array = ev.inputBuffer.getChannelData (0).slice (0);
|
||||
temp_buffers[ ++temp_buffer_index ] = float_array;
|
||||
|
||||
if (--jumps === 0)
|
||||
{
|
||||
requestAnimationFrame(function(){
|
||||
jumps = 4;
|
||||
app.engine.wavesurfer.DrawTemp ( starting_offset, temp_buffers );
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
this.isActive = function () {
|
||||
return (is_active);
|
||||
};
|
||||
|
||||
this.setEndingOffset = function ( ending_offset_seconds ) {
|
||||
ending_offset = ending_offset_seconds; // #### * 100
|
||||
};
|
||||
|
||||
this.start = function ( _at_offset, _end_callback, _start_callback, _sample_rate ) {
|
||||
if (is_active) return (false);
|
||||
if (!navigator.mediaDevices)
|
||||
{
|
||||
app.fireEvent ('ErrorRec');
|
||||
app.fireEvent ('ShowError', 'No recording device found');
|
||||
return (false);
|
||||
}
|
||||
|
||||
starting_offset = _at_offset / 1;
|
||||
if (isNaN (starting_offset) || !starting_offset) starting_offset = 0;
|
||||
curr_offset = starting_offset;
|
||||
|
||||
audio_context = app.engine.wavesurfer.backend.getAudioContext ();
|
||||
if (!audio_context)
|
||||
{
|
||||
app.fireEvent ('ErrorRec');
|
||||
app.fireEvent ('ShowError', 'No recording device found');
|
||||
return (false);
|
||||
}
|
||||
|
||||
if (audio_context.currentTime === 0) {
|
||||
app.engine.wavesurfer.backend.source.start (0);
|
||||
app.engine.wavesurfer.backend.source.stop (0);
|
||||
}
|
||||
|
||||
if (!_sample_rate)
|
||||
{
|
||||
if (app.engine.wavesurfer.backend.buffer) {
|
||||
sample_rate = app.engine.wavesurfer.backend.buffer.sampleRate;
|
||||
}
|
||||
else {
|
||||
sample_rate = audio_context.sampleRate;
|
||||
}
|
||||
}
|
||||
|
||||
end_record_func = function (offset, buffers, _callback) {
|
||||
async function downsampleAudioBuffer(buffers, sourceSampleRate, targetSampleRate) {
|
||||
// Step 1: Concatenate the Float32Array chunks
|
||||
const totalLength = buffers.reduce((sum, buf) => sum + buf.length, 0);
|
||||
const concatenated = new Float32Array(totalLength);
|
||||
let offset = 0;
|
||||
for (let i = 0; i < buffers.length; i++) {
|
||||
concatenated.set(buffers[i], offset);
|
||||
offset += buffers[i].length;
|
||||
}
|
||||
|
||||
// Step 2: Create an AudioBuffer from the concatenated data at the source sample rate
|
||||
// Create a temporary AudioContext to build the AudioBuffer
|
||||
const tempCtx = new AudioContext({ sampleRate: sourceSampleRate });
|
||||
const audioBuffer = tempCtx.createBuffer(1, totalLength, sourceSampleRate);
|
||||
audioBuffer.copyToChannel(concatenated, 0, 0);
|
||||
|
||||
// Release the temporary context if you don't need it anymore
|
||||
tempCtx.close();
|
||||
|
||||
// Step 3: Use an OfflineAudioContext to resample the AudioBuffer to the target sample rate
|
||||
const duration = audioBuffer.duration;
|
||||
const newLength = Math.ceil(duration * targetSampleRate);
|
||||
const offlineCtx = new OfflineAudioContext(1, newLength, targetSampleRate);
|
||||
|
||||
const source = offlineCtx.createBufferSource();
|
||||
source.buffer = audioBuffer;
|
||||
source.connect(offlineCtx.destination);
|
||||
source.start(0);
|
||||
|
||||
// Render the resampled AudioBuffer
|
||||
const renderedBuffer = await offlineCtx.startRendering();
|
||||
|
||||
// Return the downsampled Float32Array
|
||||
return renderedBuffer.getChannelData(0);
|
||||
}
|
||||
|
||||
var source_sample_rate = audio_context ? audio_context.sampleRate : 48000;
|
||||
if (source_sample_rate === sample_rate) {
|
||||
_callback ();
|
||||
_end_callback (offset, buffers);
|
||||
return ;
|
||||
}
|
||||
|
||||
downsampleAudioBuffer(buffers, source_sample_rate, sample_rate).then(newBuffer => {
|
||||
_callback ();
|
||||
_end_callback (offset, [newBuffer]);
|
||||
}).catch(error => {
|
||||
_callback ();
|
||||
console.error("Error during downsampling:", error);
|
||||
});
|
||||
|
||||
// _end_callback (offset, buffers);
|
||||
};
|
||||
start_record_func = _start_callback;
|
||||
|
||||
navigator.mediaDevices.getUserMedia({ audio: true, video: false }).then(function( stream ) {
|
||||
audio_stream = stream;
|
||||
media_stream_source = audio_context.createMediaStreamSource ( stream );
|
||||
|
||||
script_processor = audio_context.createScriptProcessor (
|
||||
buffer_size, channel_num, channel_num_out
|
||||
);
|
||||
|
||||
media_stream_source.connect ( script_processor );
|
||||
script_processor.connect ( audio_context.destination );
|
||||
|
||||
is_active = true;
|
||||
start_record_func && start_record_func ();
|
||||
|
||||
script_processor.onaudioprocess = fetchBufferFunction;
|
||||
}).catch(function(error) {
|
||||
app.fireEvent ('ErrorRec');
|
||||
|
||||
if (error && error.message)
|
||||
{
|
||||
app.fireEvent ('ShowError', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
return (true);
|
||||
};
|
||||
|
||||
this.stop = function ( cancel_recording ) {
|
||||
if (!is_active) return ;
|
||||
|
||||
// fire one last callback to clean temp_buffers?
|
||||
audio_stream.getTracks().forEach(function (stream) {
|
||||
stream.stop ();
|
||||
});
|
||||
|
||||
script_processor.onaudioprocess = null;
|
||||
media_stream_source.disconnect ();
|
||||
script_processor.disconnect ();
|
||||
|
||||
app.engine.wavesurfer.DrawTemp ( null );
|
||||
|
||||
if (temp_buffers.length > 0 && !cancel_recording)
|
||||
end_record_func && end_record_func ( starting_offset / sample_rate, temp_buffers, function (){
|
||||
is_active = false;
|
||||
});
|
||||
else
|
||||
end_record_func && end_record_func ( null, null, function(){
|
||||
is_active = false;
|
||||
});
|
||||
|
||||
sample_rate = 0;
|
||||
first_skip = 8;
|
||||
jumps = 1;
|
||||
temp_buffer_index = -1;
|
||||
starting_offset = ending_offset = 0;
|
||||
temp_buffers = [];
|
||||
audio_stream = null; audio_context = null;
|
||||
end_record_func = start_record_func = null;
|
||||
};
|
||||
// ---
|
||||
};
|
||||
|
||||
PKAE._deps.rec = PKREC;
|
||||
|
||||
})( window, document, PKAudioEditor );
|
||||
@@ -0,0 +1,296 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<script>window.update = function(){};</script>
|
||||
|
||||
<title>AudioMass - Spectrum Analyzer</title>
|
||||
<style>
|
||||
html,body{height:100%}
|
||||
body{padding:0;margin:0;background:#111;position:relative;z-index:2;overflow:hidden;}
|
||||
div{position:absolute;top:50%;left:0;right:0;z-index:1;margin-top:-18px;text-align:center;user-select:none;
|
||||
font-size:28px;color:#191919;pointer-events:none;opacity:.75;font-family:Arial}
|
||||
#e, #d, #f{
|
||||
cursor:pointer;
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
right: 7px;
|
||||
display: block;
|
||||
height: 20px;
|
||||
z-index: 99999;
|
||||
background: #eaeaea;
|
||||
left:auto;
|
||||
border-radius:4px;
|
||||
font-family: sans-serif;
|
||||
font-size: 9px;
|
||||
width: 82px;
|
||||
line-height: 20px;
|
||||
padding: 0 6px;
|
||||
text-align: center;
|
||||
opacity:0.9;
|
||||
user-select:none;
|
||||
}
|
||||
|
||||
.b #e, .b #d{
|
||||
top:12px;
|
||||
}
|
||||
|
||||
.b #e, .b #f{
|
||||
display:block;
|
||||
}
|
||||
|
||||
#e{
|
||||
right: 106px;
|
||||
width: 10px;
|
||||
line-height: 20px;
|
||||
padding: 0px 6px;
|
||||
text-align: center;
|
||||
opacity: 0.9;
|
||||
display:none;
|
||||
}
|
||||
|
||||
#f{
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
line-height: 0;
|
||||
border-radius: 0;
|
||||
height: 4px;
|
||||
background: #333;
|
||||
display:none;
|
||||
user-select:none;
|
||||
-moz-user-select:none;
|
||||
}
|
||||
|
||||
.c #f, #f:hover{
|
||||
background:#3C3C3C;
|
||||
}
|
||||
|
||||
</style>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="fr" width="600" height="188" style="width:100%;height:100%;display:block"></canvas>
|
||||
<div>SPECTRUM ANALYSER</div>
|
||||
|
||||
<a id="d" onclick="dock()">DOCK INTERFACE</a>
|
||||
<a id="e" onclick="remove()">X</a>
|
||||
<a id="f" onmousedown="drag(event)"></a>
|
||||
|
||||
<script>
|
||||
var d = document;
|
||||
var w = window;
|
||||
var iframe = location.href.indexOf('?iframe') === -1 ? 0 : 1;
|
||||
var canvas = d.getElementById('fr');
|
||||
var ctx = canvas.getContext('2d', {alpha:true, antialias:false});
|
||||
|
||||
w.remove = function (){};
|
||||
if (iframe) {
|
||||
d.body.className = 'b';
|
||||
w.remove = function () {
|
||||
w.parent.PKAudioEditor.ui.Dock ('RequestShowFreqAn', 'sp', [1, 1]);
|
||||
};
|
||||
};
|
||||
|
||||
w.drag = function ( e ) {
|
||||
e.preventDefault ();
|
||||
e.stopPropagation ();
|
||||
|
||||
w.parent.PKAudioEditor.ui.Dock ('RequestDragI', 'sp');
|
||||
};
|
||||
|
||||
w.dock = function () {
|
||||
if (!iframe)
|
||||
{
|
||||
if (!w.opener || !w.opener.PKAudioEditor) {
|
||||
return ;
|
||||
}
|
||||
|
||||
w.opener.PKAudioEditor.ui.Dock ('RequestShowFreqAn', 'sp', [1, 1]);
|
||||
w.close && w.close ();
|
||||
}
|
||||
else
|
||||
{
|
||||
var frm = w.parent.document.getElementById ('pk_fr' + 'sp');
|
||||
var t = 1;
|
||||
|
||||
if (frm && frm.getBoundingClientRect) {
|
||||
var rect = frm.getBoundingClientRect ();
|
||||
t = [(w.parent.screenLeft + rect.left + 100)||0, (w.parent.screenTop + rect.top + 25)||0];
|
||||
}
|
||||
|
||||
w.parent.PKAudioEditor.ui.Dock ('RequestShowFreqAn', 'sp', [t, 0]);
|
||||
}
|
||||
};
|
||||
|
||||
w.getGrayColor = function(value) {
|
||||
return 'rgb(V, V, V)'.replace(/V/g, 255 - value);
|
||||
};
|
||||
|
||||
w.getFullColor = function(value) {
|
||||
var colorPalette = {
|
||||
0: [0,0,0],
|
||||
10: [75, 0, 159],
|
||||
20: [104,0,251],
|
||||
30: [131,0,255],
|
||||
40: [155,18,157],
|
||||
50: [175, 37, 0],
|
||||
60: [191, 59, 0],
|
||||
70: [206, 88, 0],
|
||||
80: [223, 132, 0],
|
||||
90: [240, 188, 0],
|
||||
100: [255, 252, 0]
|
||||
};
|
||||
|
||||
//floor to nearest 10:
|
||||
var decimalised = 100 * value / 255
|
||||
var percent = decimalised / 100;
|
||||
var floored = 10* Math.floor(decimalised / 10);
|
||||
var distFromFloor = decimalised - floored;
|
||||
var distFromFloorPercentage = distFromFloor/10;
|
||||
var rangeToNextColor;
|
||||
|
||||
if (decimalised < 100){
|
||||
rangeToNextColor = [
|
||||
colorPalette[floored + 10][0] - colorPalette[floored + 10][0],
|
||||
colorPalette[floored + 10][1] - colorPalette[floored + 10][1],
|
||||
colorPalette[floored + 10][2] - colorPalette[floored + 10][2]
|
||||
];
|
||||
} else {
|
||||
rangeToNextColor = [0,0,0];
|
||||
}
|
||||
|
||||
var color = [
|
||||
colorPalette[floored][0] + distFromFloorPercentage * rangeToNextColor[0],
|
||||
colorPalette[floored][1] + distFromFloorPercentage * rangeToNextColor[1],
|
||||
colorPalette[floored][2] + distFromFloorPercentage * rangeToNextColor[2]
|
||||
];
|
||||
|
||||
return "rgb(" + color[0] +", "+color[1] +"," + color[2]+")";
|
||||
};
|
||||
|
||||
setTimeout(function () {
|
||||
if (!iframe)
|
||||
{
|
||||
if (!w.opener || !w.opener.PKAudioEditor) return ;
|
||||
}
|
||||
|
||||
var WIDTH = w.innerWidth, HEIGHT = w.innerHeight;
|
||||
|
||||
if (canvas.width != WIDTH)
|
||||
{
|
||||
canvas.width = WIDTH;
|
||||
canvas.height = HEIGHT;
|
||||
}
|
||||
|
||||
var bufferLength = 240;
|
||||
var value_changed = false;
|
||||
var speed = 3;
|
||||
|
||||
var tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = WIDTH;
|
||||
tempCanvas.height = HEIGHT;
|
||||
|
||||
// console.log(this.canvas.height, this.tempCanvas.height);
|
||||
var tempCtx = tempCanvas.getContext ('2d');
|
||||
|
||||
ctx.clearRect (0, 0, WIDTH, HEIGHT);
|
||||
|
||||
function draw( data ) {
|
||||
|
||||
var WIDTH = window.innerWidth, HEIGHT = window.innerHeight;
|
||||
|
||||
if (canvas.width != WIDTH || canvas.height != HEIGHT)
|
||||
{
|
||||
canvas.width = WIDTH;
|
||||
canvas.height = HEIGHT;
|
||||
tempCanvas.width = WIDTH;
|
||||
tempCanvas.height = HEIGHT;
|
||||
}
|
||||
|
||||
tempCtx.drawImage (canvas, 0, 0, WIDTH, HEIGHT);
|
||||
|
||||
ctx.fillStyle = 'rgb(0, 0, 0)';
|
||||
ctx.fillRect(0, 0, WIDTH, HEIGHT);
|
||||
|
||||
// Iterate over the frequencies.
|
||||
for (var i = 0; i < data.length; ++i)
|
||||
{
|
||||
var value;
|
||||
// Draw each pixel with the specific color.
|
||||
// if (this.log) {
|
||||
// logIndex = this.logScale(i, data.length);
|
||||
// value = data[logIndex];
|
||||
// } else {
|
||||
value = data[i];
|
||||
// }
|
||||
|
||||
ctx.fillStyle = window.getFullColor(value);
|
||||
|
||||
var percent = i / data.length;
|
||||
var y = Math.round (percent * HEIGHT);
|
||||
|
||||
// draw the line at the right side of the canvas
|
||||
ctx.fillRect(WIDTH - speed, HEIGHT - y,
|
||||
speed, speed);
|
||||
}
|
||||
|
||||
// Translate the canvas.
|
||||
ctx.translate(-speed, 0);
|
||||
// Draw the copied image.
|
||||
// console.log(this.width, this.height);
|
||||
ctx.drawImage (tempCanvas, 0, 0, WIDTH, HEIGHT,
|
||||
0, 0, WIDTH, HEIGHT);
|
||||
|
||||
// Reset the transformation matrix.
|
||||
ctx.setTransform (1, 0, 0, 1, 0, 0);
|
||||
|
||||
value_changed = false;
|
||||
};
|
||||
w.draw = draw;
|
||||
|
||||
w.onunload = function () {
|
||||
w.destroy && w.destroy ( iframe );
|
||||
w.destroy = null;
|
||||
};
|
||||
|
||||
w.update = function (freq_arr) {
|
||||
if (!freq_arr)
|
||||
ctx.clearRect (0, 0, WIDTH, HEIGHT);
|
||||
else {
|
||||
if (value_changed) return ;
|
||||
value_changed = true;
|
||||
|
||||
window.requestAnimationFrame(function () {
|
||||
draw (freq_arr);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
var last_press = 0;
|
||||
document.addEventListener ('keypress', function ( e ) {
|
||||
if (e.keyCode !== 32) return ;
|
||||
|
||||
e.preventDefault ();
|
||||
e.stopPropagation ();
|
||||
|
||||
if (e.timeStamp - last_press < 100) {
|
||||
return ;
|
||||
}
|
||||
|
||||
last_press = e.timeStamp;
|
||||
|
||||
if (!iframe) {
|
||||
w.opener && w.opener.PKAudioEditor.ui.Dock ('RequestKeyDown', 32);
|
||||
}
|
||||
else {
|
||||
w.parent && w.parent.PKAudioEditor.ui.Dock ('RequestKeyDown', 32);
|
||||
}
|
||||
});
|
||||
|
||||
}, 60);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,117 @@
|
||||
(function ( PKAE ) {
|
||||
'use strict';
|
||||
|
||||
function PKState ( _depth, app ) {
|
||||
if (!_depth) _depth = 1;
|
||||
|
||||
var q = this;
|
||||
|
||||
var _id = 1;
|
||||
var _fireEvent = app.fireEvent;
|
||||
var _listenFor = app.listenFor;
|
||||
|
||||
var undo_state_list = [];
|
||||
var redo_state_list = [];
|
||||
|
||||
q.getLastUndoState = function () {
|
||||
return (undo_state_list [ undo_state_list.length - 1]);
|
||||
};
|
||||
|
||||
q.pushUndoState = function ( state ) {
|
||||
if (!state) return (false);
|
||||
|
||||
if (!state.id) state.id = ++_id;
|
||||
if (undo_state_list.length >= _depth) undo_state_list.shift ();
|
||||
|
||||
if (undo_state_list.length > 0)
|
||||
{
|
||||
if (undo_state_list[undo_state_list.length - 1].id !== state.id - 1)
|
||||
undo_state_list = [];
|
||||
}
|
||||
if (redo_state_list.length > 0)
|
||||
{
|
||||
if (redo_state_list[0].id !== state.id + 1)
|
||||
redo_state_list = [];
|
||||
}
|
||||
|
||||
undo_state_list.push ( state );
|
||||
|
||||
_fireEvent ( 'StatePush', undo_state_list.length );
|
||||
_fireEvent ( 'DidStateChange', undo_state_list, redo_state_list);
|
||||
|
||||
return (true);
|
||||
};
|
||||
|
||||
q.popUndoState = function () {
|
||||
var last_state =undo_state_list.pop ();
|
||||
|
||||
if (last_state) {
|
||||
if (redo_state_list.length > 0)
|
||||
{
|
||||
if (redo_state_list[0].id !== last_state.id + 1)
|
||||
redo_state_list = [];
|
||||
}
|
||||
|
||||
var temp = app.engine.wavesurfer.backend.buffer;
|
||||
_fireEvent ( 'StateDidPop', last_state, 1 );
|
||||
|
||||
last_state.data = temp;
|
||||
redo_state_list.unshift (last_state);
|
||||
|
||||
_fireEvent ( 'DidStateChange', undo_state_list, redo_state_list);
|
||||
}
|
||||
|
||||
return (last_state);
|
||||
};
|
||||
|
||||
q.shiftRedoState = function () {
|
||||
var last_state = redo_state_list.shift ();
|
||||
|
||||
if (last_state) {
|
||||
if (undo_state_list.length > 0)
|
||||
{
|
||||
if (undo_state_list[undo_state_list.length - 1].id !== last_state.id - 1)
|
||||
undo_state_list = [];
|
||||
}
|
||||
|
||||
var temp = app.engine.wavesurfer.backend.buffer;
|
||||
_fireEvent ( 'StateDidPop', last_state, 0 );
|
||||
|
||||
last_state.data = temp;
|
||||
undo_state_list.push (last_state);
|
||||
|
||||
_fireEvent ( 'DidStateChange', undo_state_list, redo_state_list);
|
||||
}
|
||||
|
||||
return (last_state);
|
||||
};
|
||||
|
||||
q.clearAllState = function () {
|
||||
undo_state_list = [];
|
||||
redo_state_list = [];
|
||||
|
||||
_fireEvent ( 'StateClearAll' );
|
||||
_fireEvent ( 'DidStateChange', [], []);
|
||||
};
|
||||
|
||||
_listenFor ('StateRequestPush', function ( _state ) {
|
||||
q.pushUndoState ( _state );
|
||||
});
|
||||
_listenFor ('StateRequestUndo', function () {
|
||||
q.popUndoState ();
|
||||
});
|
||||
_listenFor ('StateRequestRedo', function () {
|
||||
q.shiftRedoState ();
|
||||
});
|
||||
_listenFor ('StateRequestClearAll', function () {
|
||||
q.clearAllState ();
|
||||
});
|
||||
_listenFor ('StateRequestLastState', function () {
|
||||
_fireEvent ('StateDidLastState', q.getLastUndoState ());
|
||||
});
|
||||
// -
|
||||
};
|
||||
|
||||
PKAE._deps.state = PKState;
|
||||
|
||||
})( PKAudioEditor );
|
||||
@@ -0,0 +1,59 @@
|
||||
const assets = [
|
||||
'./manifest.json',
|
||||
'./ico.png',
|
||||
'./icon.png',
|
||||
'./index.html',
|
||||
'./main.css',
|
||||
'./dist/wavesurfer.js',
|
||||
'./dist/plugin/wavesurfer.regions.js',
|
||||
'./oneup.js',
|
||||
'./app.js',
|
||||
'./keys.js',
|
||||
'./contextmenu.js',
|
||||
'./ui-fx.js',
|
||||
'./ui.js',
|
||||
'./modal.js',
|
||||
'./state.js',
|
||||
'./engine.js',
|
||||
'./actions.js',
|
||||
'./drag.js',
|
||||
'./recorder.js',
|
||||
'./welcome.js',
|
||||
'./fx-pg-eq.js',
|
||||
'./fx-auto.js',
|
||||
'./local.js',
|
||||
'./id3.js',
|
||||
'./lzma.js',
|
||||
'./lame.js',
|
||||
'./fonts/icomoon.ttf',
|
||||
'./fonts/icomoon.woff',
|
||||
'./favicon.ico',
|
||||
'./eq.html',
|
||||
'./sp.html',
|
||||
'./test.mp3'
|
||||
|
||||
];
|
||||
|
||||
self.addEventListener( 'install', async function () {
|
||||
const cache = await caches.open( 'audiomass' );
|
||||
assets.forEach( function ( asset ) {
|
||||
cache.add( asset ).catch( function () {
|
||||
console.error( '[SW] Cound\'t cache:', asset );
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
self.addEventListener( 'fetch', async function ( event ) {
|
||||
const request = event.request;
|
||||
event.respondWith( cacheFirst( request ) );
|
||||
});
|
||||
|
||||
async function cacheFirst( request ) {
|
||||
const cachedResponse = await caches.match( request );
|
||||
if ( cachedResponse === undefined ) {
|
||||
console.error( '[SW] Not cached:', request.url );
|
||||
return fetch( request );
|
||||
}
|
||||
|
||||
return cachedResponse;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
function interleave(inputL, inputR) {
|
||||
var length = inputL.length + inputR.length;
|
||||
var result = new Int16Array(length);
|
||||
|
||||
var index = 0,
|
||||
inputIndex = 0;
|
||||
|
||||
while (index < length) {
|
||||
result[index++] = inputL[inputIndex];
|
||||
result[index++] = inputR[inputIndex];
|
||||
++inputIndex;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function floatTo16BitPCM(output, offset, input) {
|
||||
for (var i = 0; i < input.length; i++, offset += 2) {
|
||||
output.setInt16(offset, input[i], true);
|
||||
}
|
||||
}
|
||||
|
||||
function writeString(view, offset, string) {
|
||||
for (var i = 0; i < string.length; i++) {
|
||||
view.setUint8(offset + i, string.charCodeAt(i));
|
||||
}
|
||||
}
|
||||
|
||||
function encodeWAV(samples, numChannels, sampleRate) {
|
||||
var buffer = new ArrayBuffer(44 + samples.length * 2);
|
||||
var view = new DataView(buffer);
|
||||
|
||||
/* RIFF identifier */
|
||||
writeString(view, 0, 'RIFF');
|
||||
/* RIFF chunk length */
|
||||
view.setUint32(4, 36 + samples.length * 2, true);
|
||||
/* RIFF type */
|
||||
writeString(view, 8, 'WAVE');
|
||||
/* format chunk identifier */
|
||||
writeString(view, 12, 'fmt ');
|
||||
/* format chunk length */
|
||||
view.setUint32(16, 16, true);
|
||||
/* sample format (raw) */
|
||||
view.setUint16(20, 1, true);
|
||||
/* channel count */
|
||||
view.setUint16(22, numChannels, true);
|
||||
/* sample rate */
|
||||
view.setUint32(24, sampleRate, true);
|
||||
/* byte rate (sample rate * block align) */
|
||||
view.setUint32(28, sampleRate * numChannels * 2, true);
|
||||
/* block align (channel count * bytes per sample) */
|
||||
view.setUint16(32, numChannels * 2, true);
|
||||
/* bits per sample */
|
||||
view.setUint16(34, 16, true);
|
||||
/* data chunk identifier */
|
||||
writeString(view, 36, 'data');
|
||||
/* data chunk length */
|
||||
view.setUint32(40, samples.length * 2, true);
|
||||
|
||||
floatTo16BitPCM(view, 44, samples);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
|
||||
|
||||
var sample_rate = 44100;
|
||||
var kbps = 128;
|
||||
var channels = 1;
|
||||
var mp3encoder = null;
|
||||
|
||||
var samples_left = null;
|
||||
var samples_right = null;
|
||||
var first_buffer = true;
|
||||
|
||||
onmessage = function( ev ) {
|
||||
if (!ev.data) return ;
|
||||
|
||||
if (ev.data.sample_rate) {
|
||||
sample_rate = ev.data.sample_rate / 1;
|
||||
kbps = ev.data.kbps / 1;
|
||||
channels = ev.data.channels / 1;
|
||||
|
||||
return ;
|
||||
}
|
||||
|
||||
if (first_buffer) {
|
||||
samples_left = new Int16Array (ev.data, 0);
|
||||
first_buffer = false;
|
||||
|
||||
if (channels > 1) return ;
|
||||
}
|
||||
|
||||
if (ev.data && channels > 1) {
|
||||
samples_right = new Int16Array (ev.data, 0);
|
||||
}
|
||||
|
||||
///
|
||||
var interleaved = undefined;
|
||||
if (channels > 1) {
|
||||
interleaved = interleave(samples_left, samples_right);
|
||||
} else {
|
||||
interleaved = samples_left;
|
||||
}
|
||||
|
||||
var dataview = encodeWAV(interleaved, channels, sample_rate);
|
||||
var audioBlob = new Blob([dataview], { type: 'audio/wav' });
|
||||
|
||||
postMessage( audioBlob );
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
(function ( w, d, PKAE ) {
|
||||
'use strict';
|
||||
|
||||
setTimeout(function () {
|
||||
|
||||
PKAudioEditor._deps.Wlc = function () {
|
||||
var body_str = '';
|
||||
var body_str2 = '';
|
||||
|
||||
if (PKAE.isMobile) {
|
||||
change -= 15;
|
||||
body_str = 'Tips:<br/>Please make sure your device is not in silent mode. You might need to physically flip the silent switch. '+
|
||||
'<img src="phone-switch.jpg" style="max-width:224px;max-height:126px;width:40%;margin: 10px auto; display: block;"/>'+
|
||||
'<br/><br/>';
|
||||
}
|
||||
else {
|
||||
body_str = 'Tips:<br/>Please keep in mind that most key shortcuts rely on the <strong>Shift + <u>key</u></strong> combo. (eg Shift+Z for undo, Shift+C copy, Shift+X cut... etc )<br/><br/>';
|
||||
body_str2 = 'Check out the codebase on <a href="https://github.com/pkalogiros/audiomass" target="_blank">Github</a><br/><br/>'; // checkout the code on github
|
||||
}
|
||||
|
||||
// Welcome to AudioMass,
|
||||
var md = new PKSimpleModal({
|
||||
title: '<font style="font-size:15px">Welcome to AudioMass</font>',
|
||||
ondestroy: function( q ) {
|
||||
PKAE.ui.InteractionHandler.on = false;
|
||||
PKAE.ui.KeyHandler.removeCallback ('modalTemp');
|
||||
},
|
||||
body:'<div style="overflow:auto;-webkit-overflow-scrolling:touch;max-width:580px;width:calc(100vw - 40px);max-height:calc(100vh - 340px);min-height:110px;font-size:13px; color:#95c6c6;padding-top:7px;">'+
|
||||
'AudioMass is a free, open source, web-based Audio and Waveform Editor.<br />It runs entirely in the browser with no backend and no plugins required!'+
|
||||
'<br/><br/><br/>'+
|
||||
body_str+
|
||||
'You can load any type of audio your browser supports and perform operations such as fade in, cut, trim, change the volume, '+
|
||||
'and apply a plethora of audio effects.<br/><br/>'+
|
||||
body_str2+
|
||||
'</div>',
|
||||
setup:function( q ) {
|
||||
PKAE.ui.InteractionHandler.checkAndSet ('modal');
|
||||
PKAE.ui.KeyHandler.addCallback ('modalTemp', function ( e ) {
|
||||
q.Destroy ();
|
||||
}, [27]);
|
||||
|
||||
// ------
|
||||
var scroll = q.el_body.getElementsByTagName('div')[0];
|
||||
scroll.addEventListener ('touchstart', function(e){
|
||||
e.stopPropagation ();
|
||||
}, false);
|
||||
scroll.addEventListener ('touchmove', function(e){
|
||||
e.stopPropagation ();
|
||||
}, false);
|
||||
|
||||
// ------
|
||||
}
|
||||
});
|
||||
md.Show ();
|
||||
document.getElementsByClassName('pk_modal_cancel')[0].innerHTML = ' OK ';
|
||||
};
|
||||
|
||||
var change = 96;
|
||||
var exists = w.localStorage && w.localStorage.getItem ('k');
|
||||
|
||||
if (!exists) {
|
||||
change = 0;
|
||||
w.localStorage && w.localStorage.setItem ('k', 1);
|
||||
}
|
||||
|
||||
if ( ((Math.random () * 100) >> 0) < change) return ;
|
||||
PKAudioEditor._deps.Wlc ();
|
||||
|
||||
}, 320);
|
||||
|
||||
})( window, document, PKAudioEditor );
|
||||
@@ -0,0 +1,296 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
interface AlbumCoverProps {
|
||||
seed: string;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'full';
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
// Seeded random number generator for consistent results
|
||||
class SeededRandom {
|
||||
private seed: number;
|
||||
|
||||
constructor(seed: string) {
|
||||
this.seed = this.hashString(seed);
|
||||
}
|
||||
|
||||
private hashString(str: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
return Math.abs(hash) || 1;
|
||||
}
|
||||
|
||||
next(): number {
|
||||
this.seed = (this.seed * 1103515245 + 12345) & 0x7fffffff;
|
||||
return this.seed / 0x7fffffff;
|
||||
}
|
||||
|
||||
range(min: number, max: number): number {
|
||||
return min + this.next() * (max - min);
|
||||
}
|
||||
|
||||
int(min: number, max: number): number {
|
||||
return Math.floor(this.range(min, max));
|
||||
}
|
||||
|
||||
pick<T>(arr: T[]): T {
|
||||
return arr[this.int(0, arr.length)];
|
||||
}
|
||||
}
|
||||
|
||||
// Curated color palettes - music-themed combinations
|
||||
const palettes = [
|
||||
// Sunset Vibes
|
||||
{ colors: ['#FF6B6B', '#FEC89A', '#FFD93D', '#C9184A'], bg: '#1a1a2e' },
|
||||
// Ocean Depths
|
||||
{ colors: ['#0077B6', '#00B4D8', '#90E0EF', '#CAF0F8'], bg: '#03045E' },
|
||||
// Forest Night
|
||||
{ colors: ['#2D6A4F', '#40916C', '#52B788', '#95D5B2'], bg: '#1B4332' },
|
||||
// Neon Dreams
|
||||
{ colors: ['#F72585', '#7209B7', '#3A0CA3', '#4CC9F0'], bg: '#10002B' },
|
||||
// Golden Hour
|
||||
{ colors: ['#FF9500', '#FF5400', '#FFBD00', '#FFE066'], bg: '#2D1B00' },
|
||||
// Arctic Aurora
|
||||
{ colors: ['#48CAE4', '#00F5D4', '#9B5DE5', '#F15BB5'], bg: '#0A0A1A' },
|
||||
// Lavender Haze
|
||||
{ colors: ['#E0AAFF', '#C77DFF', '#9D4EDD', '#7B2CBF'], bg: '#240046' },
|
||||
// Cherry Blossom
|
||||
{ colors: ['#FFCCD5', '#FFB3C1', '#FF758F', '#C9184A'], bg: '#2B0A14' },
|
||||
// Cyber Punk
|
||||
{ colors: ['#00FF87', '#60EFFF', '#FF00E5', '#FFE500'], bg: '#0D0D0D' },
|
||||
// Deep Space
|
||||
{ colors: ['#7400B8', '#5E60CE', '#4EA8DE', '#56CFE1'], bg: '#03071E' },
|
||||
// Warm Ember
|
||||
{ colors: ['#FFBA08', '#FAA307', '#F48C06', '#E85D04'], bg: '#370617' },
|
||||
// Cool Mint
|
||||
{ colors: ['#64DFDF', '#72EFDD', '#80FFDB', '#5EEAD4'], bg: '#0D3B3B' },
|
||||
// Velvet Rose
|
||||
{ colors: ['#9D174D', '#BE185D', '#DB2777', '#EC4899'], bg: '#1C0A14' },
|
||||
// Electric Blue
|
||||
{ colors: ['#0EA5E9', '#38BDF8', '#7DD3FC', '#E0F2FE'], bg: '#0C1929' },
|
||||
// Jungle Fever
|
||||
{ colors: ['#84CC16', '#A3E635', '#BEF264', '#ECFCCB'], bg: '#1A2E05' },
|
||||
];
|
||||
|
||||
type PatternType = 'aurora' | 'mesh' | 'orbs' | 'rays' | 'waves' | 'geometric' | 'nebula' | 'gradient' | 'rings' | 'crystal';
|
||||
|
||||
const generatePattern = (rng: SeededRandom, palette: typeof palettes[0]): React.CSSProperties => {
|
||||
const patterns: PatternType[] = ['aurora', 'mesh', 'orbs', 'rays', 'waves', 'geometric', 'nebula', 'gradient', 'rings', 'crystal'];
|
||||
const pattern = rng.pick(patterns);
|
||||
const colors = palette.colors;
|
||||
const bg = palette.bg;
|
||||
|
||||
switch (pattern) {
|
||||
case 'aurora': {
|
||||
const angle1 = rng.int(0, 360);
|
||||
const angle2 = rng.int(0, 360);
|
||||
return {
|
||||
background: `
|
||||
linear-gradient(${angle1}deg, ${colors[0]}00 0%, ${colors[0]}88 25%, ${colors[1]}88 50%, ${colors[2]}88 75%, ${colors[3]}00 100%),
|
||||
linear-gradient(${angle2}deg, ${colors[2]}00 0%, ${colors[3]}66 30%, ${colors[0]}66 70%, ${colors[1]}00 100%),
|
||||
radial-gradient(ellipse at ${rng.int(20, 80)}% ${rng.int(60, 100)}%, ${colors[1]}44 0%, transparent 50%),
|
||||
linear-gradient(180deg, ${bg} 0%, ${colors[3]}22 100%)
|
||||
`,
|
||||
backgroundColor: bg,
|
||||
};
|
||||
}
|
||||
|
||||
case 'mesh': {
|
||||
const points = [
|
||||
{ x: rng.int(0, 40), y: rng.int(0, 40) },
|
||||
{ x: rng.int(60, 100), y: rng.int(0, 40) },
|
||||
{ x: rng.int(0, 40), y: rng.int(60, 100) },
|
||||
{ x: rng.int(60, 100), y: rng.int(60, 100) },
|
||||
];
|
||||
return {
|
||||
background: `
|
||||
radial-gradient(at ${points[0].x}% ${points[0].y}%, ${colors[0]} 0%, transparent 50%),
|
||||
radial-gradient(at ${points[1].x}% ${points[1].y}%, ${colors[1]} 0%, transparent 50%),
|
||||
radial-gradient(at ${points[2].x}% ${points[2].y}%, ${colors[2]} 0%, transparent 50%),
|
||||
radial-gradient(at ${points[3].x}% ${points[3].y}%, ${colors[3]} 0%, transparent 50%)
|
||||
`,
|
||||
backgroundColor: bg,
|
||||
};
|
||||
}
|
||||
|
||||
case 'orbs': {
|
||||
const orbCount = rng.int(3, 6);
|
||||
const orbs = Array.from({ length: orbCount }, (_, i) => {
|
||||
const size = rng.int(30, 70);
|
||||
const x = rng.int(10, 90);
|
||||
const y = rng.int(10, 90);
|
||||
const color = colors[i % colors.length];
|
||||
const blur = rng.int(20, 40);
|
||||
return `radial-gradient(circle ${size}% at ${x}% ${y}%, ${color}99 0%, ${color}44 ${blur}%, transparent 70%)`;
|
||||
});
|
||||
return {
|
||||
background: [...orbs, `linear-gradient(135deg, ${bg} 0%, ${colors[0]}11 100%)`].join(', '),
|
||||
backgroundColor: bg,
|
||||
};
|
||||
}
|
||||
|
||||
case 'rays': {
|
||||
const centerX = rng.int(30, 70);
|
||||
const centerY = rng.int(30, 70);
|
||||
const rayCount = rng.int(6, 12);
|
||||
const rays = Array.from({ length: rayCount }, (_, i) => {
|
||||
const angle = (360 / rayCount) * i + rng.int(-10, 10);
|
||||
const color = colors[i % colors.length];
|
||||
return `linear-gradient(${angle}deg, transparent 0%, transparent 45%, ${color}66 48%, ${color}66 52%, transparent 55%, transparent 100%)`;
|
||||
});
|
||||
return {
|
||||
background: [
|
||||
`radial-gradient(circle at ${centerX}% ${centerY}%, ${colors[0]} 0%, transparent 30%)`,
|
||||
...rays,
|
||||
].join(', '),
|
||||
backgroundColor: bg,
|
||||
};
|
||||
}
|
||||
|
||||
case 'waves': {
|
||||
const waveAngle = rng.int(0, 180);
|
||||
const waveSize = rng.int(8, 20);
|
||||
return {
|
||||
background: `
|
||||
repeating-linear-gradient(
|
||||
${waveAngle}deg,
|
||||
${colors[0]}44 0px,
|
||||
${colors[1]}44 ${waveSize}px,
|
||||
${colors[2]}44 ${waveSize * 2}px,
|
||||
${colors[3]}44 ${waveSize * 3}px,
|
||||
${colors[0]}44 ${waveSize * 4}px
|
||||
),
|
||||
radial-gradient(ellipse at 50% 0%, ${colors[0]}66 0%, transparent 70%),
|
||||
radial-gradient(ellipse at 50% 100%, ${colors[2]}66 0%, transparent 70%)
|
||||
`,
|
||||
backgroundColor: bg,
|
||||
};
|
||||
}
|
||||
|
||||
case 'geometric': {
|
||||
const angle = rng.int(0, 90);
|
||||
return {
|
||||
background: `
|
||||
conic-gradient(from ${angle}deg at 50% 50%, ${colors[0]}, ${colors[1]}, ${colors[2]}, ${colors[3]}, ${colors[0]}),
|
||||
repeating-conic-gradient(from 0deg at 50% 50%, ${bg}00 0deg, ${bg}88 ${90/rng.int(2,6)}deg)
|
||||
`,
|
||||
backgroundBlendMode: 'overlay',
|
||||
backgroundColor: bg,
|
||||
};
|
||||
}
|
||||
|
||||
case 'nebula': {
|
||||
const x1 = rng.int(20, 80);
|
||||
const y1 = rng.int(20, 80);
|
||||
const x2 = rng.int(20, 80);
|
||||
const y2 = rng.int(20, 80);
|
||||
return {
|
||||
background: `
|
||||
radial-gradient(ellipse ${rng.int(60, 100)}% ${rng.int(40, 80)}% at ${x1}% ${y1}%, ${colors[0]}88 0%, transparent 50%),
|
||||
radial-gradient(ellipse ${rng.int(40, 80)}% ${rng.int(60, 100)}% at ${x2}% ${y2}%, ${colors[1]}88 0%, transparent 50%),
|
||||
radial-gradient(ellipse ${rng.int(50, 90)}% ${rng.int(50, 90)}% at ${100-x1}% ${100-y1}%, ${colors[2]}66 0%, transparent 60%),
|
||||
radial-gradient(ellipse ${rng.int(30, 60)}% ${rng.int(30, 60)}% at ${100-x2}% ${100-y2}%, ${colors[3]}44 0%, transparent 70%),
|
||||
linear-gradient(${rng.int(0, 360)}deg, ${bg} 0%, ${colors[0]}22 50%, ${bg} 100%)
|
||||
`,
|
||||
backgroundColor: bg,
|
||||
};
|
||||
}
|
||||
|
||||
case 'gradient': {
|
||||
const angle = rng.int(0, 360);
|
||||
const type = rng.int(0, 3);
|
||||
if (type === 0) {
|
||||
return {
|
||||
background: `linear-gradient(${angle}deg, ${colors[0]} 0%, ${colors[1]} 33%, ${colors[2]} 66%, ${colors[3]} 100%)`,
|
||||
};
|
||||
} else if (type === 1) {
|
||||
return {
|
||||
background: `
|
||||
radial-gradient(circle at ${rng.int(30, 70)}% ${rng.int(30, 70)}%, ${colors[0]} 0%, ${colors[1]} 30%, ${colors[2]} 60%, ${colors[3]} 100%)
|
||||
`,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
background: `
|
||||
linear-gradient(${angle}deg, ${colors[0]} 0%, ${colors[0]} 25%, transparent 25%, transparent 75%, ${colors[2]} 75%),
|
||||
linear-gradient(${angle + 90}deg, ${colors[1]} 0%, ${colors[1]} 25%, transparent 25%, transparent 75%, ${colors[3]} 75%),
|
||||
linear-gradient(${angle}deg, ${colors[2]} 0%, ${colors[3]} 100%)
|
||||
`,
|
||||
backgroundBlendMode: 'multiply, screen, normal',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
case 'rings': {
|
||||
const centerX = rng.int(30, 70);
|
||||
const centerY = rng.int(30, 70);
|
||||
return {
|
||||
background: `
|
||||
repeating-radial-gradient(circle at ${centerX}% ${centerY}%,
|
||||
${colors[0]}66 0px, ${colors[0]}66 2px,
|
||||
transparent 2px, transparent ${rng.int(15, 25)}px,
|
||||
${colors[1]}66 ${rng.int(15, 25)}px, ${colors[1]}66 ${rng.int(17, 27)}px,
|
||||
transparent ${rng.int(17, 27)}px, transparent ${rng.int(35, 50)}px
|
||||
),
|
||||
radial-gradient(circle at ${centerX}% ${centerY}%, ${colors[2]}88 0%, transparent 60%),
|
||||
linear-gradient(${rng.int(0, 180)}deg, ${colors[3]}44, ${colors[0]}44)
|
||||
`,
|
||||
backgroundColor: bg,
|
||||
};
|
||||
}
|
||||
|
||||
case 'crystal': {
|
||||
const facets = rng.int(4, 8);
|
||||
const gradients = Array.from({ length: facets }, (_, i) => {
|
||||
const startAngle = (360 / facets) * i;
|
||||
const color = colors[i % colors.length];
|
||||
return `conic-gradient(from ${startAngle}deg at ${50 + rng.int(-20, 20)}% ${50 + rng.int(-20, 20)}%, ${color}88 0deg, transparent ${360/facets}deg)`;
|
||||
});
|
||||
return {
|
||||
background: [
|
||||
...gradients,
|
||||
`radial-gradient(circle at 50% 50%, ${colors[0]}44 0%, transparent 70%)`,
|
||||
].join(', '),
|
||||
backgroundColor: bg,
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
background: `linear-gradient(135deg, ${colors[0]}, ${colors[1]})`,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const AlbumCover: React.FC<AlbumCoverProps> = ({ seed, size = 'md', className = '', children }) => {
|
||||
const coverStyle = useMemo(() => {
|
||||
const rng = new SeededRandom(seed);
|
||||
const palette = rng.pick(palettes);
|
||||
return generatePattern(rng, palette);
|
||||
}, [seed]);
|
||||
|
||||
const sizeClasses: Record<string, string> = {
|
||||
xs: 'w-8 h-8',
|
||||
sm: 'w-10 h-10',
|
||||
md: 'w-12 h-12',
|
||||
lg: 'w-14 h-14',
|
||||
xl: 'w-48 h-48',
|
||||
full: 'w-full h-full',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${sizeClasses[size]} rounded-md shadow-lg flex-shrink-0 overflow-hidden relative ${className}`}
|
||||
style={coverStyle}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AlbumCover;
|
||||
@@ -0,0 +1,307 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { X, Camera, Image as ImageIcon, Upload, Loader2 } from 'lucide-react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { usersApi, UserProfile } from '../services/api';
|
||||
|
||||
interface EditProfileModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
export const EditProfileModal: React.FC<EditProfileModalProps> = ({ isOpen, onClose, onSaved }) => {
|
||||
const { user, token, refreshUser, updateUsername } = useAuth();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
|
||||
const [editUsername, setEditUsername] = useState('');
|
||||
const [editBio, setEditBio] = useState('');
|
||||
const [editAvatarUrl, setEditAvatarUrl] = useState('');
|
||||
const [editBannerUrl, setEditBannerUrl] = useState('');
|
||||
const [usernameError, setUsernameError] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
const [bannerFile, setBannerFile] = useState<File | null>(null);
|
||||
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||||
const [bannerPreview, setBannerPreview] = useState<string | null>(null);
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||
const [uploadingBanner, setUploadingBanner] = useState(false);
|
||||
const avatarInputRef = useRef<HTMLInputElement>(null);
|
||||
const bannerInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && user && token) {
|
||||
loadProfile();
|
||||
}
|
||||
}, [isOpen, user, token]);
|
||||
|
||||
const loadProfile = async () => {
|
||||
if (!user || !token) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await usersApi.getProfile(user.username, token);
|
||||
setProfile(res.user);
|
||||
setEditUsername(res.user.username || '');
|
||||
setEditBio(res.user.bio || '');
|
||||
setEditAvatarUrl(res.user.avatar_url || '');
|
||||
setEditBannerUrl(res.user.banner_url || '');
|
||||
setUsernameError('');
|
||||
} catch (error) {
|
||||
console.error('Failed to load profile:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setAvatarFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => setAvatarPreview(ev.target?.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBannerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setBannerFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => setBannerPreview(ev.target?.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
if (!token || !profile) return;
|
||||
setIsSaving(true);
|
||||
setUsernameError('');
|
||||
|
||||
try {
|
||||
// Update username if changed
|
||||
if (editUsername && editUsername !== profile.username) {
|
||||
const sanitized = editUsername.trim().replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
if (sanitized.length < 2) {
|
||||
setUsernameError('Username must be at least 2 characters');
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateUsername(sanitized);
|
||||
} catch (err: unknown) {
|
||||
const error = err as Error & { message?: string };
|
||||
if (error.message?.includes('taken')) {
|
||||
setUsernameError('Username is already taken');
|
||||
} else {
|
||||
setUsernameError('Failed to update username');
|
||||
}
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (avatarFile) {
|
||||
setUploadingAvatar(true);
|
||||
const avatarRes = await usersApi.uploadAvatar(avatarFile, token);
|
||||
setEditAvatarUrl(avatarRes.url);
|
||||
setUploadingAvatar(false);
|
||||
}
|
||||
|
||||
if (bannerFile) {
|
||||
setUploadingBanner(true);
|
||||
const bannerRes = await usersApi.uploadBanner(bannerFile, token);
|
||||
setEditBannerUrl(bannerRes.url);
|
||||
setUploadingBanner(false);
|
||||
}
|
||||
|
||||
const updates: Record<string, string> = { bio: editBio };
|
||||
if (!avatarFile && editAvatarUrl !== profile.avatar_url) {
|
||||
updates.avatarUrl = editAvatarUrl;
|
||||
}
|
||||
if (!bannerFile && editBannerUrl !== profile.banner_url) {
|
||||
updates.bannerUrl = editBannerUrl;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await usersApi.updateProfile(updates, token);
|
||||
}
|
||||
|
||||
await refreshUser();
|
||||
handleClose();
|
||||
onSaved?.();
|
||||
} catch (error) {
|
||||
console.error('Failed to update profile:', error);
|
||||
alert('Failed to update profile');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
setUploadingAvatar(false);
|
||||
setUploadingBanner(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setAvatarFile(null);
|
||||
setBannerFile(null);
|
||||
setAvatarPreview(null);
|
||||
setBannerPreview(null);
|
||||
setUsernameError('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
|
||||
<div className="w-full max-w-lg bg-zinc-900 border border-zinc-800 rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="px-6 py-4 border-b border-zinc-800 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-white">Edit Profile</h2>
|
||||
<button onClick={handleClose} className="text-zinc-400 hover:text-white transition-colors">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="p-12 flex items-center justify-center">
|
||||
<Loader2 size={32} className="animate-spin text-zinc-400" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Username Input */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-300">Username</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-zinc-500">@</span>
|
||||
<input
|
||||
type="text"
|
||||
value={editUsername}
|
||||
onChange={(e) => {
|
||||
setEditUsername(e.target.value);
|
||||
setUsernameError('');
|
||||
}}
|
||||
placeholder="username"
|
||||
maxLength={50}
|
||||
className="flex-1 bg-black border border-zinc-800 rounded-lg px-3 py-2 text-white placeholder-zinc-600 focus:outline-none focus:border-indigo-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
{usernameError && (
|
||||
<p className="text-sm text-red-500">{usernameError}</p>
|
||||
)}
|
||||
<p className="text-xs text-zinc-500">Letters, numbers, underscores, and hyphens only</p>
|
||||
</div>
|
||||
|
||||
{/* Avatar Upload */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-300">Avatar Image</label>
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="w-20 h-20 rounded-full bg-zinc-800 border-2 border-zinc-700 border-dashed overflow-hidden flex-shrink-0 relative">
|
||||
{(avatarPreview || editAvatarUrl) ? (
|
||||
<img
|
||||
src={avatarPreview || editAvatarUrl}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => (e.currentTarget.style.display = 'none')}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-zinc-500">
|
||||
<Camera size={24} />
|
||||
</div>
|
||||
)}
|
||||
{uploadingAvatar && (
|
||||
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
|
||||
<Loader2 size={20} className="animate-spin text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
onChange={handleAvatarChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
<Upload size={16} />
|
||||
Upload Avatar
|
||||
</button>
|
||||
<p className="text-xs text-zinc-500">JPG, PNG, WebP, GIF - Max 5MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Banner Upload */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-300">Banner Image</label>
|
||||
<div
|
||||
onClick={() => bannerInputRef.current?.click()}
|
||||
className="relative w-full h-32 rounded-lg bg-zinc-800 border-2 border-zinc-700 border-dashed overflow-hidden cursor-pointer hover:border-zinc-600 transition-colors"
|
||||
>
|
||||
{(bannerPreview || editBannerUrl) ? (
|
||||
<img
|
||||
src={bannerPreview || editBannerUrl}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => (e.currentTarget.style.display = 'none')}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center text-zinc-500 gap-2">
|
||||
<ImageIcon size={32} />
|
||||
<span className="text-sm">Click to upload banner</span>
|
||||
</div>
|
||||
)}
|
||||
{uploadingBanner && (
|
||||
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
|
||||
<Loader2 size={24} className="animate-spin text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={bannerInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
onChange={handleBannerChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<p className="text-xs text-zinc-500">Recommended: 1500x500px - JPG, PNG, WebP, GIF - Max 5MB</p>
|
||||
</div>
|
||||
|
||||
{/* Bio Input */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-300">Bio</label>
|
||||
<textarea
|
||||
value={editBio}
|
||||
onChange={(e) => setEditBio(e.target.value)}
|
||||
placeholder="Tell us about yourself..."
|
||||
rows={4}
|
||||
className="w-full bg-black border border-zinc-800 rounded-lg px-3 py-2 text-white placeholder-zinc-600 focus:outline-none focus:border-indigo-500 transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 bg-black/20 border-t border-zinc-800 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="px-4 py-2 text-sm font-medium text-zinc-300 hover:text-white transition-colors"
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveProfile}
|
||||
disabled={isSaving || uploadingAvatar || uploadingBanner}
|
||||
className="px-6 py-2 bg-white text-black hover:bg-zinc-200 rounded-full text-sm font-bold transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{isSaving && <Loader2 size={16} className="animate-spin" />}
|
||||
{uploadingAvatar ? 'Uploading Avatar...' : uploadingBanner ? 'Uploading Banner...' : isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Song, Playlist } from '../types';
|
||||
import { Heart, Plus, Music, Play } from 'lucide-react';
|
||||
import { AlbumCover } from './AlbumCover';
|
||||
|
||||
interface LibraryViewProps {
|
||||
likedSongs: Song[];
|
||||
playlists: Playlist[];
|
||||
onPlaySong: (song: Song, list?: Song[]) => void;
|
||||
onCreatePlaylist: () => void;
|
||||
onSelectPlaylist: (playlist: Playlist) => void;
|
||||
}
|
||||
|
||||
export const LibraryView: React.FC<LibraryViewProps> = ({
|
||||
likedSongs,
|
||||
playlists,
|
||||
onPlaySong,
|
||||
onCreatePlaylist,
|
||||
onSelectPlaylist
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<'playlists' | 'liked'>('liked');
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-white dark:bg-black overflow-y-auto custom-scrollbar p-6 lg:p-10 pb-32 transition-colors duration-300">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<h1 className="text-3xl font-bold text-zinc-900 dark:text-white">Your Library</h1>
|
||||
<button
|
||||
onClick={onCreatePlaylist}
|
||||
className="flex items-center gap-2 bg-zinc-900 dark:bg-zinc-800 hover:bg-zinc-800 dark:hover:bg-zinc-700 text-white px-4 py-2 rounded-full font-medium transition-colors shadow-lg shadow-zinc-900/10 dark:shadow-none"
|
||||
>
|
||||
<Plus size={18} />
|
||||
<span>New Playlist</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-4 mb-8 border-b border-zinc-200 dark:border-white/10 pb-1">
|
||||
<button
|
||||
onClick={() => setActiveTab('liked')}
|
||||
className={`pb-3 text-sm font-bold transition-colors relative ${activeTab === 'liked' ? 'text-zinc-900 dark:text-white' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-white'}`}
|
||||
>
|
||||
Liked Songs
|
||||
{activeTab === 'liked' && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-green-500 rounded-full"></div>}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('playlists')}
|
||||
className={`pb-3 text-sm font-bold transition-colors relative ${activeTab === 'playlists' ? 'text-zinc-900 dark:text-white' : 'text-zinc-500 hover:text-zinc-900 dark:hover:text-white'}`}
|
||||
>
|
||||
Playlists
|
||||
{activeTab === 'playlists' && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-green-500 rounded-full"></div>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{activeTab === 'liked' ? (
|
||||
<div>
|
||||
<div className="bg-gradient-to-b from-indigo-500/10 to-zinc-50 dark:from-indigo-800/50 dark:to-zinc-900/50 p-6 rounded-xl flex items-end gap-6 mb-8 cursor-pointer hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors group border border-zinc-200 dark:border-white/5" onClick={() => likedSongs.length > 0 && onPlaySong(likedSongs[0], likedSongs)}>
|
||||
<div className="w-40 h-40 bg-gradient-to-br from-indigo-500 to-purple-400 rounded shadow-2xl flex items-center justify-center">
|
||||
<Heart fill="white" size={64} className="text-white" />
|
||||
</div>
|
||||
<div className="mb-2">
|
||||
<h2 className="text-sm font-bold uppercase text-zinc-500 dark:text-white mb-2">Playlist</h2>
|
||||
<h1 className="text-5xl font-extrabold text-zinc-900 dark:text-white mb-4">Liked Songs</h1>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-300 font-medium">
|
||||
{likedSongs.length} songs
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto mb-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="w-14 h-14 rounded-full bg-green-500 flex items-center justify-center shadow-lg hover:scale-105 transition-transform">
|
||||
<Play fill="black" className="text-black ml-1" size={28} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{likedSongs.map((song, idx) => (
|
||||
<div key={song.id} className="group flex items-center gap-4 p-2 rounded hover:bg-zinc-100 dark:hover:bg-white/10 transition-colors" onClick={() => onPlaySong(song, likedSongs)}>
|
||||
<span className="text-zinc-400 dark:text-zinc-500 w-6 text-center group-hover:hidden">{idx + 1}</span>
|
||||
<span className="text-zinc-900 dark:text-white w-6 text-center hidden group-hover:block"><Play size={14} fill="currentColor" /></span>
|
||||
|
||||
{song.coverUrl ? (
|
||||
<img src={song.coverUrl} className="w-10 h-10 rounded object-cover shadow-sm" alt="" onError={(e) => { e.currentTarget.style.display = 'none'; }} />
|
||||
) : (
|
||||
<AlbumCover seed={song.id || song.title} size="sm" className="w-10 h-10" />
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-zinc-900 dark:text-white font-medium truncate">{song.title}</div>
|
||||
<div className="text-zinc-500 dark:text-zinc-400 text-xs">{song.style}</div>
|
||||
</div>
|
||||
|
||||
<div className="text-zinc-500 dark:text-zinc-400 text-sm font-mono">{song.duration}</div>
|
||||
<div className="text-green-500"><Heart fill="#22c55e" size={16} /></div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-6">
|
||||
{playlists.map((playlist) => (
|
||||
<div key={playlist.id} className="bg-white dark:bg-zinc-900/40 p-4 rounded-lg border border-zinc-200 dark:border-white/5 hover:border-zinc-300 dark:hover:border-white/10 hover:shadow-lg dark:hover:bg-zinc-900 transition-all group cursor-pointer" onClick={() => onSelectPlaylist(playlist)}>
|
||||
<div className="relative aspect-square mb-4 rounded-md overflow-hidden bg-zinc-100 dark:bg-zinc-800 flex items-center justify-center">
|
||||
{playlist.coverUrl ? (
|
||||
<img src={playlist.coverUrl} className="w-full h-full object-cover" alt={playlist.name} onError={(e) => { e.currentTarget.style.display = 'none'; }} />
|
||||
) : (
|
||||
<AlbumCover seed={playlist.id || playlist.name} size="full" className="w-full h-full" />
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-bold text-zinc-900 dark:text-white truncate">{playlist.name}</h3>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 line-clamp-2">{playlist.description || `By You`}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import React, { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface MobileDrawerProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
position: 'left' | 'right';
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function MobileDrawer({ isOpen, onClose, position, children, title }: MobileDrawerProps): React.ReactElement | null {
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
const drawerRef = useRef<HTMLDivElement>(null);
|
||||
const previousActiveElement = useRef<HTMLElement | null>(null);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setIsClosing(true);
|
||||
setTimeout(() => {
|
||||
setIsClosing(false);
|
||||
onClose();
|
||||
}, 300);
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
previousActiveElement.current = document.activeElement as HTMLElement;
|
||||
drawerRef.current?.focus();
|
||||
} else if (previousActiveElement.current) {
|
||||
previousActiveElement.current.focus();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && isOpen && !isClosing) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
return () => document.removeEventListener('keydown', handleEscape);
|
||||
}, [isOpen, isClosing, handleClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !drawerRef.current) return;
|
||||
|
||||
const drawer = drawerRef.current;
|
||||
const focusableElements = drawer.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
const firstElement = focusableElements[0];
|
||||
const lastElement = focusableElements[focusableElements.length - 1];
|
||||
|
||||
const handleTabKey = (event: KeyboardEvent) => {
|
||||
if (event.key !== 'Tab') return;
|
||||
|
||||
if (event.shiftKey) {
|
||||
if (document.activeElement === firstElement) {
|
||||
event.preventDefault();
|
||||
lastElement?.focus();
|
||||
}
|
||||
} else {
|
||||
if (document.activeElement === lastElement) {
|
||||
event.preventDefault();
|
||||
firstElement?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
drawer.addEventListener('keydown', handleTabKey);
|
||||
return () => drawer.removeEventListener('keydown', handleTabKey);
|
||||
}, [isOpen]);
|
||||
|
||||
const handleBackdropClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (event.target === event.currentTarget && !isClosing) {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen && !isClosing) return null;
|
||||
|
||||
const slideAnimation = isClosing
|
||||
? position === 'left' ? 'drawer-slide-out-left' : 'drawer-slide-out-right'
|
||||
: position === 'left' ? 'drawer-slide-in-left' : 'drawer-slide-in-right';
|
||||
|
||||
const backdropAnimation = isClosing ? 'drawer-backdrop-out' : 'drawer-backdrop-in';
|
||||
|
||||
const positionClasses = position === 'left' ? 'left-0' : 'right-0';
|
||||
|
||||
const content = (
|
||||
<div
|
||||
className={`fixed inset-0 z-50 ${backdropAnimation}`}
|
||||
onClick={handleBackdropClick}
|
||||
role="presentation"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/70 backdrop-blur-mobile" />
|
||||
<div
|
||||
ref={drawerRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={title ? 'drawer-title' : undefined}
|
||||
tabIndex={-1}
|
||||
className={`fixed top-0 ${positionClasses} h-full w-80 max-w-[85vw] bg-zinc-900 shadow-2xl border-white/10 ${slideAnimation} safe-area-inset-y safe-area-inset-${position} flex flex-col`}
|
||||
style={{ borderWidth: position === 'left' ? '0 1px 0 0' : '0 0 0 1px' }}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/10 shrink-0">
|
||||
{title && (
|
||||
<h2 id="drawer-title" className="text-lg font-semibold text-white">
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
{!title && <div />}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-2 hover:bg-white/10 rounded-lg transition-colors text-zinc-400 hover:text-white tap-highlight-none"
|
||||
aria-label="Close drawer"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain scroll-touch">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(content, document.body);
|
||||
}
|
||||
@@ -0,0 +1,723 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { Song } from '../types';
|
||||
import { Play, Pause, SkipBack, SkipForward, Repeat, Shuffle, Download, Heart, MoreVertical, Volume2, VolumeX, Maximize2, Repeat1, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useResponsive } from '../context/ResponsiveContext';
|
||||
import { SongDropdownMenu } from './SongDropdownMenu';
|
||||
import { ShareModal } from './ShareModal';
|
||||
import { AlbumCover } from './AlbumCover';
|
||||
|
||||
interface PlayerProps {
|
||||
currentSong: Song | null;
|
||||
isPlaying: boolean;
|
||||
onTogglePlay: () => void;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
onSeek: (time: number) => void;
|
||||
onNext: () => void;
|
||||
onPrevious: () => void;
|
||||
volume: number;
|
||||
onVolumeChange: (val: number) => void;
|
||||
isShuffle: boolean;
|
||||
onToggleShuffle: () => void;
|
||||
repeatMode: 'none' | 'all' | 'one';
|
||||
onToggleRepeat: () => void;
|
||||
isLiked: boolean;
|
||||
onToggleLike: () => void;
|
||||
onNavigateToSong?: (songId: string) => void;
|
||||
onOpenVideo?: () => void;
|
||||
onReusePrompt?: () => void;
|
||||
onAddToPlaylist?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
export const Player: React.FC<PlayerProps> = ({
|
||||
currentSong,
|
||||
isPlaying,
|
||||
onTogglePlay,
|
||||
currentTime,
|
||||
duration,
|
||||
onSeek,
|
||||
onNext,
|
||||
onPrevious,
|
||||
volume,
|
||||
onVolumeChange,
|
||||
isShuffle,
|
||||
onToggleShuffle,
|
||||
repeatMode,
|
||||
onToggleRepeat,
|
||||
isLiked,
|
||||
onToggleLike,
|
||||
onNavigateToSong,
|
||||
onOpenVideo,
|
||||
onReusePrompt,
|
||||
onAddToPlaylist,
|
||||
onDelete
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const { isMobile } = useResponsive();
|
||||
const progressBarRef = useRef<HTMLDivElement>(null);
|
||||
const fullscreenProgressRef = useRef<HTMLDivElement>(null);
|
||||
const [isHoveringVolume, setIsHoveringVolume] = useState(false);
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
|
||||
// Close fullscreen on Escape key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isFullscreen) {
|
||||
setIsFullscreen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isFullscreen]);
|
||||
|
||||
if (!currentSong) return null;
|
||||
|
||||
const formatTime = (time: number) => {
|
||||
if (isNaN(time)) return "0:00";
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = Math.floor(time % 60);
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const handleSeekInteraction = (e: React.MouseEvent<HTMLDivElement>, ref: React.RefObject<HTMLDivElement>) => {
|
||||
if (!ref.current || !duration) return;
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const width = rect.width;
|
||||
const percentage = Math.max(0, Math.min(1, x / width));
|
||||
onSeek(percentage * duration);
|
||||
};
|
||||
|
||||
const progressPercent = duration ? (currentTime / duration) * 100 : 0;
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!currentSong?.audioUrl) return;
|
||||
try {
|
||||
const response = await fetch(currentSong.audioUrl);
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${currentSong.title || 'song'}.mp3`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Download failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (isMobile) {
|
||||
if (isFullscreen) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-gradient-to-b from-zinc-100 to-zinc-50 dark:from-zinc-900 dark:to-black flex flex-col safe-area-inset-top safe-area-inset-bottom transition-colors duration-300">
|
||||
{/* Header with close button */}
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<button
|
||||
onClick={() => setIsFullscreen(false)}
|
||||
className="p-2 text-zinc-600 dark:text-white/70 tap-highlight-none"
|
||||
>
|
||||
<ChevronDown size={28} />
|
||||
</button>
|
||||
<span className="text-xs text-zinc-500 dark:text-white/50 uppercase tracking-wider">Now Playing</span>
|
||||
<div className="w-11" />
|
||||
</div>
|
||||
|
||||
{/* Album Art */}
|
||||
<div className="flex-1 flex items-center justify-center px-8 py-4">
|
||||
<div className="w-full max-w-[280px] aspect-square rounded-lg overflow-hidden shadow-2xl">
|
||||
{currentSong.coverUrl ? (
|
||||
<img
|
||||
src={currentSong.coverUrl}
|
||||
className="w-full h-full object-cover"
|
||||
alt="cover"
|
||||
onError={(e) => { e.currentTarget.style.display = 'none'; e.currentTarget.nextElementSibling?.classList.remove('hidden'); }}
|
||||
/>
|
||||
) : null}
|
||||
<AlbumCover seed={currentSong.id || currentSong.title} size="full" className={`w-full h-full ${currentSong.coverUrl ? 'hidden' : ''}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Song Info */}
|
||||
<div className="px-6 mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0 mr-4">
|
||||
<h2
|
||||
onClick={() => {
|
||||
setIsFullscreen(false);
|
||||
onNavigateToSong?.(currentSong.id);
|
||||
}}
|
||||
className="text-xl font-bold text-zinc-900 dark:text-white truncate"
|
||||
>
|
||||
{currentSong.title}
|
||||
</h2>
|
||||
<p className="text-sm text-zinc-500 dark:text-white/60 truncate mt-1">
|
||||
{currentSong.creator || 'Unknown Artist'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggleLike}
|
||||
className={`p-2 tap-highlight-none ${isLiked ? 'text-pink-600 dark:text-pink-500' : 'text-zinc-400 dark:text-white/50'}`}
|
||||
>
|
||||
<Heart size={24} fill={isLiked ? "currentColor" : "none"} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="px-6 mb-2">
|
||||
<div
|
||||
ref={fullscreenProgressRef}
|
||||
className="w-full h-1.5 bg-zinc-300 dark:bg-white/20 rounded-full cursor-pointer relative"
|
||||
onClick={(e) => handleSeekInteraction(e, fullscreenProgressRef)}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-zinc-900 dark:bg-white rounded-full relative"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
>
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-4 h-4 bg-zinc-900 dark:bg-white rounded-full shadow-lg -mr-2" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between mt-2 text-xs text-zinc-500 dark:text-white/50 font-mono">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration || 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Controls */}
|
||||
<div className="flex items-center justify-center gap-8 py-4">
|
||||
<button
|
||||
onClick={onToggleShuffle}
|
||||
className={`p-2 tap-highlight-none ${isShuffle ? 'text-pink-600 dark:text-pink-500' : 'text-zinc-400 dark:text-white/50'}`}
|
||||
>
|
||||
<Shuffle size={22} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onPrevious}
|
||||
className="p-2 text-zinc-800 dark:text-white tap-highlight-none"
|
||||
>
|
||||
<SkipBack size={32} fill="currentColor" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onTogglePlay}
|
||||
className="w-16 h-16 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-black flex items-center justify-center shadow-lg tap-highlight-none"
|
||||
>
|
||||
{isPlaying ? <Pause size={32} fill="currentColor" /> : <Play size={32} fill="currentColor" className="ml-1" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={onNext}
|
||||
className="p-2 text-zinc-800 dark:text-white tap-highlight-none"
|
||||
>
|
||||
<SkipForward size={32} fill="currentColor" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onToggleRepeat}
|
||||
className={`p-2 tap-highlight-none relative ${repeatMode !== 'none' ? 'text-pink-600 dark:text-pink-500' : 'text-zinc-400 dark:text-white/50'}`}
|
||||
>
|
||||
{repeatMode === 'one' ? <Repeat1 size={22} /> : <Repeat size={22} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Volume Control */}
|
||||
<div className="flex items-center gap-3 px-6 py-4">
|
||||
<button
|
||||
onClick={() => onVolumeChange(volume === 0 ? 0.8 : 0)}
|
||||
className="text-zinc-400 dark:text-white/50 tap-highlight-none"
|
||||
>
|
||||
{volume === 0 ? <VolumeX size={20} /> : <Volume2 size={20} />}
|
||||
</button>
|
||||
<div className="flex-1 h-1 bg-zinc-300 dark:bg-white/20 rounded-full relative">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={volume}
|
||||
onChange={(e) => onVolumeChange(parseFloat(e.target.value))}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-zinc-700 dark:bg-white/70 rounded-full"
|
||||
style={{ width: `${volume * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Extra Actions */}
|
||||
<div className="flex items-center justify-center gap-6 px-6 pb-6 text-zinc-400 dark:text-white/50">
|
||||
{onOpenVideo && (
|
||||
<button onClick={onOpenVideo} className="p-3 tap-highlight-none">
|
||||
<Maximize2 size={20} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="p-3 tap-highlight-none"
|
||||
title="Download Audio"
|
||||
>
|
||||
<Download size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className="p-3 tap-highlight-none relative"
|
||||
>
|
||||
<MoreVertical size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showDropdown && (
|
||||
<div className="absolute bottom-24 left-1/2 -translate-x-1/2">
|
||||
<SongDropdownMenu
|
||||
song={currentSong}
|
||||
isOpen={showDropdown}
|
||||
onClose={() => setShowDropdown(false)}
|
||||
isOwner={user?.id === currentSong.userId}
|
||||
position="center"
|
||||
direction="up"
|
||||
onCreateVideo={onOpenVideo}
|
||||
onReusePrompt={onReusePrompt}
|
||||
onAddToPlaylist={onAddToPlaylist}
|
||||
onDelete={onDelete}
|
||||
onShare={() => setShareModalOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-black/95 backdrop-blur border-t border-zinc-200 dark:border-white/10 flex flex-col z-50 transition-colors duration-300 safe-area-inset-bottom">
|
||||
{/* Progress Bar - taller for touch */}
|
||||
<div
|
||||
ref={progressBarRef}
|
||||
className="w-full h-1 bg-zinc-200 dark:bg-zinc-800 cursor-pointer relative"
|
||||
onClick={(e) => handleSeekInteraction(e, progressBarRef)}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-pink-600 dark:bg-pink-500"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main content: Song info left, controls right */}
|
||||
<div className="flex items-center px-3 py-2 gap-3">
|
||||
{/* Song Info - takes available space, tap to expand */}
|
||||
<div
|
||||
className="flex items-center gap-3 flex-1 min-w-0"
|
||||
onClick={() => setIsFullscreen(true)}
|
||||
>
|
||||
<div className="w-11 h-11 rounded bg-zinc-200 dark:bg-zinc-800 overflow-hidden shadow-sm flex-shrink-0 relative">
|
||||
{currentSong.coverUrl ? (
|
||||
<img src={currentSong.coverUrl} className="w-full h-full object-cover" alt="cover" onError={(e) => { e.currentTarget.style.display = 'none'; }} />
|
||||
) : null}
|
||||
{!currentSong.coverUrl && <AlbumCover seed={currentSong.id || currentSong.title} size="full" className="w-full h-full" />}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/20 opacity-0 active:opacity-100 transition-opacity">
|
||||
<ChevronUp size={20} className="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-hidden flex-1 min-w-0">
|
||||
<h4 className="text-sm font-semibold text-zinc-900 dark:text-white truncate">
|
||||
{currentSong.title}
|
||||
</h4>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400 truncate">
|
||||
{currentSong.creator || 'Unknown Artist'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Controls - compact */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<button
|
||||
onClick={onToggleLike}
|
||||
className={`p-2 tap-highlight-none ${isLiked ? 'text-pink-600 dark:text-pink-500' : 'text-zinc-400'}`}
|
||||
>
|
||||
<Heart size={20} fill={isLiked ? "currentColor" : "none"} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onPrevious}
|
||||
className="p-2 text-zinc-700 dark:text-zinc-300 tap-highlight-none"
|
||||
>
|
||||
<SkipBack size={22} fill="currentColor" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onTogglePlay}
|
||||
className="w-11 h-11 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-black flex items-center justify-center shadow-lg tap-highlight-none"
|
||||
>
|
||||
{isPlaying ? <Pause size={22} fill="currentColor" /> : <Play size={22} fill="currentColor" className="ml-0.5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={onNext}
|
||||
className="p-2 text-zinc-700 dark:text-zinc-300 tap-highlight-none"
|
||||
>
|
||||
<SkipForward size={22} fill="currentColor" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Desktop fullscreen mode
|
||||
if (isFullscreen) {
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-gradient-to-b from-zinc-100 to-zinc-50 dark:from-zinc-900 dark:to-black flex flex-col transition-colors duration-300"
|
||||
onClick={() => setIsFullscreen(false)}
|
||||
>
|
||||
{/* Header with close button */}
|
||||
<div className="flex items-center justify-between px-6 py-4" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => setIsFullscreen(false)}
|
||||
className="p-2 text-zinc-600 dark:text-white/70 hover:bg-zinc-200 dark:hover:bg-white/10 rounded-full transition-colors"
|
||||
>
|
||||
<ChevronDown size={28} />
|
||||
</button>
|
||||
<span className="text-sm text-zinc-500 dark:text-white/50 uppercase tracking-wider font-medium">Now Playing</span>
|
||||
<div className="w-11" />
|
||||
</div>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 flex items-center justify-center px-8 py-4 overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex flex-col lg:flex-row items-center gap-8 lg:gap-16 max-w-5xl w-full">
|
||||
{/* Album Art */}
|
||||
<div className="w-full max-w-[320px] lg:max-w-[400px] aspect-square rounded-lg overflow-hidden shadow-2xl flex-shrink-0">
|
||||
{currentSong.coverUrl ? (
|
||||
<img
|
||||
src={currentSong.coverUrl}
|
||||
className="w-full h-full object-cover"
|
||||
alt="cover"
|
||||
onError={(e) => { e.currentTarget.style.display = 'none'; e.currentTarget.nextElementSibling?.classList.remove('hidden'); }}
|
||||
/>
|
||||
) : null}
|
||||
<AlbumCover seed={currentSong.id || currentSong.title} size="full" className={`w-full h-full ${currentSong.coverUrl ? 'hidden' : ''}`} />
|
||||
</div>
|
||||
|
||||
{/* Right side: Song info and controls */}
|
||||
<div className="flex flex-col items-center lg:items-start gap-6 flex-1 min-w-0 max-w-lg">
|
||||
{/* Song Info */}
|
||||
<div className="text-center lg:text-left w-full">
|
||||
<h2
|
||||
onClick={() => {
|
||||
setIsFullscreen(false);
|
||||
onNavigateToSong?.(currentSong.id);
|
||||
}}
|
||||
className="text-2xl lg:text-3xl font-bold text-zinc-900 dark:text-white truncate cursor-pointer hover:underline"
|
||||
>
|
||||
{currentSong.title}
|
||||
</h2>
|
||||
<p className="text-base lg:text-lg text-zinc-500 dark:text-white/60 truncate mt-2">
|
||||
{currentSong.creator || 'Unknown Artist'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="w-full">
|
||||
<div
|
||||
ref={fullscreenProgressRef}
|
||||
className="w-full h-2 bg-zinc-300 dark:bg-white/20 rounded-full cursor-pointer relative group"
|
||||
onClick={(e) => handleSeekInteraction(e, fullscreenProgressRef)}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-zinc-900 dark:bg-white rounded-full relative group-hover:bg-pink-600 dark:group-hover:bg-pink-500 transition-colors"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
>
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-4 h-4 bg-zinc-900 dark:bg-white group-hover:bg-pink-600 dark:group-hover:bg-pink-500 rounded-full shadow-lg -mr-2 opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between mt-2 text-sm text-zinc-500 dark:text-white/50 font-mono">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{formatTime(duration || 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Controls */}
|
||||
<div className="flex items-center justify-center gap-8 py-2 w-full">
|
||||
<button
|
||||
onClick={onToggleShuffle}
|
||||
className={`p-2 transition-colors ${isShuffle ? 'text-pink-600 dark:text-pink-500' : 'text-zinc-400 hover:text-zinc-900 dark:hover:text-white'}`}
|
||||
>
|
||||
<Shuffle size={22} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onPrevious}
|
||||
className="p-2 text-zinc-800 dark:text-white hover:scale-110 transition-transform"
|
||||
>
|
||||
<SkipBack size={36} fill="currentColor" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onTogglePlay}
|
||||
className="w-18 h-18 p-5 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-black flex items-center justify-center shadow-lg hover:scale-105 transition-transform"
|
||||
>
|
||||
{isPlaying ? <Pause size={36} fill="currentColor" /> : <Play size={36} fill="currentColor" className="ml-1" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={onNext}
|
||||
className="p-2 text-zinc-800 dark:text-white hover:scale-110 transition-transform"
|
||||
>
|
||||
<SkipForward size={36} fill="currentColor" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onToggleRepeat}
|
||||
className={`p-2 transition-colors relative ${repeatMode !== 'none' ? 'text-pink-600 dark:text-pink-500' : 'text-zinc-400 hover:text-zinc-900 dark:hover:text-white'}`}
|
||||
>
|
||||
{repeatMode === 'one' ? <Repeat1 size={22} /> : <Repeat size={22} />}
|
||||
{repeatMode !== 'none' && <div className="absolute -bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 bg-current rounded-full"></div>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Volume Control */}
|
||||
<div className="flex items-center gap-4 w-full max-w-xs">
|
||||
<button
|
||||
onClick={() => onVolumeChange(volume === 0 ? 0.8 : 0)}
|
||||
className="text-zinc-500 dark:text-white/50 hover:text-zinc-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
{volume === 0 ? <VolumeX size={22} /> : <Volume2 size={22} />}
|
||||
</button>
|
||||
<div className="flex-1 h-1.5 bg-zinc-300 dark:bg-white/20 rounded-full relative">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={volume}
|
||||
onChange={(e) => onVolumeChange(parseFloat(e.target.value))}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer z-10"
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-zinc-700 dark:bg-white/70 rounded-full"
|
||||
style={{ width: `${volume * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Extra Actions */}
|
||||
<div className="flex items-center justify-center gap-4 text-zinc-400 dark:text-white/50">
|
||||
<button
|
||||
onClick={onToggleLike}
|
||||
className={`p-3 rounded-full hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors ${isLiked ? 'text-pink-600 dark:text-pink-500' : ''}`}
|
||||
>
|
||||
<Heart size={22} fill={isLiked ? "currentColor" : "none"} />
|
||||
</button>
|
||||
{onOpenVideo && (
|
||||
<button
|
||||
onClick={onOpenVideo}
|
||||
className="p-3 rounded-full hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<Maximize2 size={20} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="p-3 rounded-full hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors"
|
||||
title="Download Audio"
|
||||
>
|
||||
<Download size={20} />
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className="p-3 rounded-full hover:bg-zinc-200 dark:hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<MoreVertical size={20} />
|
||||
</button>
|
||||
{showDropdown && (
|
||||
<SongDropdownMenu
|
||||
song={currentSong}
|
||||
isOpen={showDropdown}
|
||||
onClose={() => setShowDropdown(false)}
|
||||
isOwner={user?.id === currentSong.userId}
|
||||
position="center"
|
||||
direction="up"
|
||||
onCreateVideo={onOpenVideo}
|
||||
onReusePrompt={onReusePrompt}
|
||||
onAddToPlaylist={onAddToPlaylist}
|
||||
onDelete={onDelete}
|
||||
onShare={() => setShareModalOpen(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ShareModal
|
||||
isOpen={shareModalOpen}
|
||||
onClose={() => setShareModalOpen(false)}
|
||||
song={currentSong}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-20 lg:h-24 bg-white dark:bg-black/95 backdrop-blur border-t border-zinc-200 dark:border-white/10 flex flex-col z-50 transition-colors duration-300 shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.1)] dark:shadow-none">
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div
|
||||
ref={progressBarRef}
|
||||
className="w-full h-1 lg:h-1.5 bg-zinc-200 dark:bg-zinc-800 cursor-pointer group relative"
|
||||
onClick={(e) => handleSeekInteraction(e, progressBarRef)}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-zinc-900 dark:bg-white relative group-hover:bg-pink-600 dark:group-hover:bg-pink-500 transition-colors"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
>
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-zinc-900 dark:bg-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity shadow-lg scale-150"></div>
|
||||
</div>
|
||||
{/* Hit area for easier clicking */}
|
||||
<div className="absolute top-1/2 -translate-y-1/2 w-full h-4 -z-10"></div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex items-center justify-between px-2 sm:px-4 lg:px-6 gap-2 sm:gap-4">
|
||||
|
||||
{/* Song Info */}
|
||||
<div className="flex items-center gap-2 sm:gap-3 min-w-0 flex-1 max-w-[30%] lg:max-w-[33%]">
|
||||
<div className="w-10 h-10 lg:w-12 lg:h-12 rounded bg-zinc-200 dark:bg-zinc-800 overflow-hidden shadow-sm flex-shrink-0">
|
||||
{currentSong.coverUrl ? (
|
||||
<img src={currentSong.coverUrl} className="w-full h-full object-cover" alt="cover" onError={(e) => { e.currentTarget.style.display = 'none'; }} />
|
||||
) : null}
|
||||
{!currentSong.coverUrl && <AlbumCover seed={currentSong.id || currentSong.title} size="full" className="w-full h-full" />}
|
||||
</div>
|
||||
<div className="overflow-hidden min-w-0">
|
||||
<h4
|
||||
onClick={() => onNavigateToSong?.(currentSong.id)}
|
||||
className="text-xs sm:text-sm font-bold text-zinc-900 dark:text-white truncate cursor-pointer hover:underline"
|
||||
>
|
||||
{currentSong.title}
|
||||
</h4>
|
||||
<p className="text-[10px] sm:text-xs text-zinc-500 dark:text-zinc-400 truncate hover:underline cursor-pointer">{currentSong.creator || 'Unknown Artist'}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onToggleLike}
|
||||
className={`ml-1 sm:ml-2 transition-colors flex-shrink-0 hidden sm:block ${isLiked ? 'text-pink-600 dark:text-pink-500' : 'text-zinc-400 hover:text-zinc-900 dark:hover:text-white'}`}
|
||||
>
|
||||
<Heart size={18} fill={isLiked ? "currentColor" : "none"} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex flex-col items-center justify-center flex-shrink-0">
|
||||
<div className="flex items-center gap-2 sm:gap-4 lg:gap-6">
|
||||
<button
|
||||
onClick={onToggleShuffle}
|
||||
className={`transition-colors hidden sm:block ${isShuffle ? 'text-pink-600 dark:text-pink-500' : 'text-zinc-400 hover:text-zinc-900 dark:hover:text-white'}`}
|
||||
>
|
||||
<Shuffle size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onPrevious}
|
||||
className="text-zinc-700 dark:text-zinc-300 hover:text-black dark:hover:text-white transition-colors"
|
||||
>
|
||||
<SkipBack size={18} className="sm:w-[22px] sm:h-[22px]" fill="currentColor" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onTogglePlay}
|
||||
className="w-9 h-9 sm:w-10 sm:h-10 rounded-full bg-zinc-900 dark:bg-white text-white dark:text-black flex items-center justify-center hover:scale-105 transition-transform shadow-lg"
|
||||
>
|
||||
{isPlaying ? <Pause size={18} className="sm:w-5 sm:h-5" fill="currentColor" /> : <Play size={18} className="sm:w-5 sm:h-5 ml-0.5" fill="currentColor" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={onNext}
|
||||
className="text-zinc-700 dark:text-zinc-300 hover:text-black dark:hover:text-white transition-colors"
|
||||
>
|
||||
<SkipForward size={18} className="sm:w-[22px] sm:h-[22px]" fill="currentColor" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onToggleRepeat}
|
||||
className={`transition-colors hidden sm:block ${repeatMode !== 'none' ? 'text-pink-600 dark:text-pink-500' : 'text-zinc-400 hover:text-zinc-900 dark:hover:text-white'} relative`}
|
||||
>
|
||||
{repeatMode === 'one' ? <Repeat1 size={16} /> : <Repeat size={16} />}
|
||||
{repeatMode !== 'none' && <div className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 w-1 h-1 bg-current rounded-full"></div>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Volume & Extras */}
|
||||
<div className="flex items-center justify-end gap-1 sm:gap-2 lg:gap-3 min-w-0 flex-1 max-w-[30%] lg:max-w-[33%] text-zinc-500 dark:text-zinc-400">
|
||||
<span className="text-[10px] sm:text-xs font-mono text-right text-zinc-600 dark:text-zinc-400 hidden md:block">
|
||||
{formatTime(currentTime)} / {formatTime(duration || 0)}
|
||||
</span>
|
||||
|
||||
<div
|
||||
className="items-center gap-2 relative group hidden md:flex"
|
||||
onMouseEnter={() => setIsHoveringVolume(true)}
|
||||
onMouseLeave={() => setIsHoveringVolume(false)}
|
||||
>
|
||||
<button
|
||||
onClick={() => onVolumeChange(volume === 0 ? 0.8 : 0)}
|
||||
className="p-1.5 lg:p-2 hover:bg-zinc-100 dark:hover:bg-white/10 rounded-full transition-colors"
|
||||
>
|
||||
{volume === 0 ? <VolumeX size={18} /> : <Volume2 size={18} />}
|
||||
</button>
|
||||
|
||||
{/* Volume Slider */}
|
||||
<div className={`h-1.5 bg-zinc-200 dark:bg-zinc-700 rounded-full cursor-pointer overflow-hidden transition-all duration-200 ${isHoveringVolume ? 'opacity-100 w-16 lg:w-24 mx-1 lg:mx-2' : 'opacity-0 w-0 mx-0 pointer-events-none'}`}>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
value={volume}
|
||||
onChange={(e) => onVolumeChange(parseFloat(e.target.value))}
|
||||
className="w-full h-full opacity-0 cursor-pointer absolute z-10"
|
||||
/>
|
||||
<div
|
||||
className="h-full bg-zinc-900 dark:bg-white rounded-full"
|
||||
style={{ width: `${volume * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="p-1.5 lg:p-2 hover:bg-zinc-100 dark:hover:bg-white/10 rounded-full transition-colors hidden lg:block"
|
||||
title="Download Audio"
|
||||
>
|
||||
<Download size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsFullscreen(true)}
|
||||
className="p-1.5 lg:p-2 hover:bg-zinc-100 dark:hover:bg-white/10 rounded-full transition-colors"
|
||||
>
|
||||
<Maximize2 size={16} />
|
||||
</button>
|
||||
<div className="relative hidden sm:block">
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className="p-1.5 lg:p-2 hover:bg-zinc-100 dark:hover:bg-white/10 rounded-full transition-colors"
|
||||
>
|
||||
<MoreVertical size={18} />
|
||||
</button>
|
||||
<SongDropdownMenu
|
||||
song={currentSong}
|
||||
isOpen={showDropdown}
|
||||
onClose={() => setShowDropdown(false)}
|
||||
isOwner={user?.id === currentSong.userId}
|
||||
position="right"
|
||||
direction="up"
|
||||
onCreateVideo={onOpenVideo}
|
||||
onReusePrompt={onReusePrompt}
|
||||
onAddToPlaylist={onAddToPlaylist}
|
||||
onDelete={onDelete}
|
||||
onShare={() => setShareModalOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ShareModal
|
||||
isOpen={shareModalOpen}
|
||||
onClose={() => setShareModalOpen(false)}
|
||||
song={currentSong}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,292 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Song, Playlist, playlistsApi, songsApi, getAudioUrl } from '../services/api';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { ArrowLeft, Play, MoreHorizontal, Clock, Calendar, Shuffle, Trash2, Mic2, Music } from 'lucide-react';
|
||||
|
||||
interface PlaylistDetailProps {
|
||||
playlistId: string;
|
||||
onBack: () => void;
|
||||
onPlaySong: (song: Song, list?: Song[]) => void;
|
||||
onSelect: (song: Song) => void;
|
||||
onNavigateToProfile: (username: string) => void;
|
||||
}
|
||||
|
||||
export const PlaylistDetail: React.FC<PlaylistDetailProps> = ({ playlistId, onBack, onPlaySong, onSelect, onNavigateToProfile }) => {
|
||||
const { user: currentUser, token } = useAuth();
|
||||
const [playlist, setPlaylist] = useState<Playlist & { creator_avatar?: string } | null>(null);
|
||||
const [songs, setSongs] = useState<Song[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadPlaylist();
|
||||
}, [playlistId]);
|
||||
|
||||
const loadPlaylist = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await playlistsApi.getPlaylist(playlistId, token);
|
||||
// res.playlist comes from DB row, which now includes creator_avatar
|
||||
setPlaylist(res.playlist as any);
|
||||
|
||||
const mappedSongs: Song[] = res.songs.map((s: any) => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
lyrics: s.lyrics,
|
||||
style: s.style,
|
||||
coverUrl: s.cover_url || s.coverUrl || `https://picsum.photos/seed/${s.id}/400/400`,
|
||||
audioUrl: getAudioUrl(s.audio_url || s.audioUrl, s.id),
|
||||
duration: s.duration,
|
||||
bpm: s.bpm,
|
||||
tags: s.tags || [],
|
||||
isPublic: s.is_public || false,
|
||||
likeCount: s.like_count || 0,
|
||||
viewCount: s.view_count || 0,
|
||||
creator: s.creator,
|
||||
created_at: s.created_at,
|
||||
addedAt: s.added_at
|
||||
}));
|
||||
|
||||
setSongs(mappedSongs);
|
||||
} catch (error) {
|
||||
console.error('Failed to load playlist:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ... (retaining methods handleRemove, handleDelete) ...
|
||||
const handleRemoveSong = async (songId: string) => {
|
||||
if (!token || !playlist) return;
|
||||
try {
|
||||
await playlistsApi.removeSong(playlist.id, songId, token);
|
||||
setSongs(prev => prev.filter(s => s.id !== songId));
|
||||
} catch (error) {
|
||||
console.error('Failed to remove song:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePlaylist = async () => {
|
||||
if (!token || !playlist) return;
|
||||
if (!confirm('Are you sure you want to delete this playlist?')) return;
|
||||
try {
|
||||
await playlistsApi.delete(playlist.id, token);
|
||||
onBack();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete playlist:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return (
|
||||
<div className="flex items-center justify-center h-full bg-black">
|
||||
<div className="text-zinc-400 gap-2 flex items-center">
|
||||
<div className="w-4 h-4 border-2 border-zinc-400 border-t-transparent rounded-full animate-spin"></div>
|
||||
Loading playlist...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!playlist) return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 bg-black">
|
||||
<div className="text-zinc-400">Playlist not found</div>
|
||||
<button onClick={onBack} className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 rounded-lg text-white">
|
||||
Go Back
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const isOwner = currentUser?.id === playlist.user_id;
|
||||
|
||||
// Gradient based on ID/Name
|
||||
const gradients = [
|
||||
'from-purple-900 to-black',
|
||||
'from-blue-900 to-black',
|
||||
'from-indigo-900 to-black',
|
||||
'from-rose-900 to-black',
|
||||
];
|
||||
const bgGradient = gradients[playlist.name.length % gradients.length];
|
||||
|
||||
return (
|
||||
<div className={`w-full h-full flex flex-col bg-gradient-to-b ${bgGradient} overflow-hidden`}>
|
||||
{/* Header */}
|
||||
<div className="flex-shrink-0 p-4 md:p-8 pt-12 md:pt-8 flex flex-col md:flex-row gap-4 md:gap-8 items-center md:items-end bg-black/20 backdrop-blur-lg border-b border-white/10">
|
||||
{/* Cover */}
|
||||
<div className="w-32 h-32 md:w-52 md:h-52 shadow-2xl rounded-lg bg-zinc-800 flex items-center justify-center overflow-hidden flex-shrink-0 group relative">
|
||||
{playlist.cover_url ? (
|
||||
<img src={playlist.cover_url} alt={playlist.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full bg-gradient-to-br from-zinc-700 to-zinc-900 flex items-center justify-center">
|
||||
<Music size={40} className="text-white/20 md:hidden" />
|
||||
<Music size={64} className="text-white/20 hidden md:block" />
|
||||
<span className="text-4xl md:text-6xl font-bold text-white/10">{playlist.name[0].toUpperCase()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 space-y-2 md:space-y-4 text-center md:text-left">
|
||||
<span className="text-xs font-bold tracking-wider uppercase text-white/80">Playlist</span>
|
||||
<h1 className="text-2xl md:text-5xl lg:text-7xl font-bold text-white tracking-tight leading-none drop-shadow-lg">
|
||||
{playlist.name}
|
||||
</h1>
|
||||
{playlist.description && (
|
||||
<p className="text-zinc-300 text-sm max-w-2xl hidden md:block">{playlist.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-center md:justify-start gap-2 text-sm text-white font-medium flex-wrap">
|
||||
{playlist.creator && (
|
||||
<div
|
||||
className="flex items-center gap-2 cursor-pointer hover:underline"
|
||||
onClick={() => onNavigateToProfile(playlist.creator!)}
|
||||
>
|
||||
{playlist.creator_avatar ? (
|
||||
<img src={playlist.creator_avatar} alt={playlist.creator} className="w-5 h-5 md:w-6 md:h-6 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-5 h-5 md:w-6 md:h-6 rounded-full bg-gradient-to-r from-green-400 to-blue-500"></div>
|
||||
)}
|
||||
<span>{playlist.creator}</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="w-1 h-1 rounded-full bg-white/50"></span>
|
||||
<span>{songs.length} songs</span>
|
||||
<span className="w-1 h-1 rounded-full bg-white/50 hidden md:block"></span>
|
||||
<span className="text-zinc-400 hidden md:block">
|
||||
{songs.reduce((acc, s) => acc + (s.duration ? (typeof s.duration === 'string' ? 0 : s.duration) : 0), 0) > 0
|
||||
? Math.floor(songs.reduce((acc, s) => acc + (s.duration as number || 0), 0) / 60) + " min"
|
||||
: ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions Bar */}
|
||||
<div className="px-4 md:px-8 py-3 md:py-4 bg-black/20 flex items-center gap-3 md:gap-4">
|
||||
<button
|
||||
onClick={() => songs.length > 0 && onPlaySong(songs[0], songs)}
|
||||
className="w-12 h-12 md:w-14 md:h-14 rounded-full bg-green-500 hover:scale-105 transition-transform flex items-center justify-center text-black shadow-lg"
|
||||
>
|
||||
<Play size={24} fill="currentColor" className="ml-1" />
|
||||
</button>
|
||||
|
||||
{isOwner && (
|
||||
<button
|
||||
onClick={handleDeletePlaylist}
|
||||
className="text-zinc-400 hover:text-red-500 transition-colors p-2"
|
||||
title="Delete Playlist"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex-1"></div>
|
||||
|
||||
<div className="text-zinc-400 text-xs md:text-sm">
|
||||
{playlist.is_public ? 'Public' : 'Private'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Song List */}
|
||||
<div className="flex-1 overflow-y-auto bg-black/40">
|
||||
<div className="px-2 md:px-8 py-2 md:py-4">
|
||||
{/* Desktop Header */}
|
||||
<div className="hidden md:grid grid-cols-[16px_4fr_3fr_2fr_minmax(120px,1fr)] gap-4 px-4 py-2 border-b border-white/10 text-sm font-medium text-zinc-400 mb-2 sticky top-0 bg-[#121212] z-10">
|
||||
<span>#</span>
|
||||
<span>Title</span>
|
||||
<span>Artist</span>
|
||||
<span>Date Added</span>
|
||||
<span className="text-right"><Clock size={16} className="inline" /></span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
{songs.map((song, index) => (
|
||||
<div
|
||||
key={song.id}
|
||||
className="group flex md:grid md:grid-cols-[16px_4fr_3fr_2fr_minmax(120px,1fr)] gap-3 md:gap-4 px-2 md:px-4 py-3 rounded-md hover:bg-white/10 items-center transition-colors text-sm text-zinc-400 hover:text-white cursor-pointer"
|
||||
onClick={() => {
|
||||
onSelect(song);
|
||||
onPlaySong(song, songs);
|
||||
}}
|
||||
>
|
||||
{/* Index - hidden on mobile */}
|
||||
<span className="hidden md:block group-hover:text-white">{index + 1}</span>
|
||||
|
||||
{/* Cover + Title */}
|
||||
<div className="flex items-center gap-3 overflow-hidden flex-1 md:flex-none">
|
||||
<div className="w-12 h-12 md:w-10 md:h-10 rounded bg-zinc-800 flex-shrink-0 overflow-hidden relative group/img">
|
||||
<img src={song.coverUrl} alt="" className="w-full h-full object-cover" />
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPlaySong(song, songs);
|
||||
}}
|
||||
className="absolute inset-0 bg-black/50 flex md:hidden group-hover/img:flex items-center justify-center text-white"
|
||||
>
|
||||
<Play size={16} fill="white" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col truncate min-w-0">
|
||||
<span className="font-medium text-white truncate">{song.title}</span>
|
||||
<span className="text-xs text-zinc-500 group-hover:text-zinc-400 truncate">
|
||||
{song.creator || 'Unknown'} <span className="md:hidden">• {song.duration ? `${Math.floor(song.duration / 60)}:${String(Math.floor(song.duration % 60)).padStart(2, '0')}` : '0:00'}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Artist - hidden on mobile */}
|
||||
<span className="hidden md:block hover:underline cursor-pointer truncate" onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
song.creator && onNavigateToProfile(song.creator);
|
||||
}}>
|
||||
{song.creator || 'Unknown'}
|
||||
</span>
|
||||
|
||||
{/* Date Added - hidden on mobile */}
|
||||
<span className="hidden md:block">
|
||||
{song.addedAt ? new Date(song.addedAt).toLocaleDateString() : 'Just now'}
|
||||
</span>
|
||||
|
||||
{/* Duration + Actions */}
|
||||
<div className="hidden md:flex items-center justify-end gap-4">
|
||||
<span className="font-mono text-xs">
|
||||
{song.duration ? `${Math.floor(song.duration / 60)}:${String(Math.floor(song.duration % 60)).padStart(2, '0')}` : '0:00'}
|
||||
</span>
|
||||
{isOwner && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveSong(song.id);
|
||||
}}
|
||||
className="opacity-0 group-hover:opacity-100 text-zinc-500 hover:text-white transition-opacity"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile delete button */}
|
||||
{isOwner && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveSong(song.id);
|
||||
}}
|
||||
className="md:hidden text-zinc-500 hover:text-white p-2"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Back button absolute */}
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="absolute top-6 left-6 z-50 w-8 h-8 rounded-full bg-black/50 flex items-center justify-center text-white hover:bg-black/70 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Plus, Music } from 'lucide-react';
|
||||
import { Playlist } from '../types';
|
||||
|
||||
interface CreatePlaylistModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreate: (name: string, description: string) => void;
|
||||
}
|
||||
|
||||
export const CreatePlaylistModal: React.FC<CreatePlaylistModalProps> = ({ isOpen, onClose, onCreate }) => {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (name.trim()) {
|
||||
onCreate(name, description);
|
||||
setName('');
|
||||
setDescription('');
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 dark:bg-black/80 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 rounded-xl w-full max-w-md p-6 shadow-2xl animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-bold text-zinc-900 dark:text-white">Create Playlist</h2>
|
||||
<button onClick={onClose} className="text-zinc-400 hover:text-zinc-900 dark:hover:text-white">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase mb-1">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full bg-zinc-50 dark:bg-black/50 border border-zinc-200 dark:border-white/10 rounded-lg p-3 text-zinc-900 dark:text-white focus:outline-none focus:border-pink-500 focus:ring-1 focus:ring-pink-500 placeholder-zinc-400 dark:placeholder-zinc-600"
|
||||
placeholder="My Awesome Playlist"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase mb-1">Description</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full bg-zinc-50 dark:bg-black/50 border border-zinc-200 dark:border-white/10 rounded-lg p-3 text-zinc-900 dark:text-white focus:outline-none focus:border-pink-500 focus:ring-1 focus:ring-pink-500 resize-none h-24 placeholder-zinc-400 dark:placeholder-zinc-600"
|
||||
placeholder="Vibes for coding..."
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-lg text-sm font-medium text-zinc-600 dark:text-zinc-300 hover:text-zinc-900 dark:hover:text-white hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!name.trim()}
|
||||
className="px-4 py-2 rounded-lg text-sm font-bold bg-zinc-900 dark:bg-white text-white dark:text-black hover:scale-105 transition-transform disabled:opacity-50 disabled:hover:scale-100 shadow-lg"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface AddToPlaylistModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
playlists: Playlist[];
|
||||
onSelect: (playlistId: string) => void;
|
||||
onCreateNew?: () => void;
|
||||
}
|
||||
|
||||
export const AddToPlaylistModal: React.FC<AddToPlaylistModalProps> = ({ isOpen, onClose, playlists, onSelect, onCreateNew }) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 dark:bg-black/80 backdrop-blur-sm p-4">
|
||||
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 rounded-xl w-full max-w-sm p-6 shadow-2xl animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">Add to Playlist</h2>
|
||||
<button onClick={onClose} className="text-zinc-400 hover:text-zinc-900 dark:hover:text-white">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create New Button - Always visible at top */}
|
||||
<button
|
||||
onClick={onCreateNew}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-lg hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors group mb-3 border border-dashed border-zinc-300 dark:border-white/20 hover:border-zinc-400 dark:hover:border-white/40"
|
||||
>
|
||||
<div className="w-10 h-10 bg-zinc-100 dark:bg-zinc-800/50 rounded flex items-center justify-center text-zinc-600 dark:text-white/70 group-hover:text-zinc-900 dark:group-hover:text-white">
|
||||
<Plus size={20} />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold text-zinc-700 dark:text-white/90 group-hover:text-zinc-900 dark:group-hover:text-white">Create New Playlist</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="h-px bg-zinc-100 dark:bg-white/10 my-2"></div>
|
||||
|
||||
<div className="space-y-1 max-h-60 overflow-y-auto custom-scrollbar">
|
||||
{playlists.length === 0 ? (
|
||||
<div className="text-center py-6 text-zinc-500 text-sm italic">
|
||||
No existing playlists.
|
||||
</div>
|
||||
) : (
|
||||
playlists.map(playlist => (
|
||||
<button
|
||||
key={playlist.id}
|
||||
onClick={() => {
|
||||
onSelect(playlist.id);
|
||||
onClose();
|
||||
}}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-lg hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors group"
|
||||
>
|
||||
<div className="w-10 h-10 bg-zinc-200 dark:bg-zinc-800 rounded flex items-center justify-center text-zinc-500 group-hover:text-zinc-900 dark:group-hover:text-white flex-shrink-0 overflow-hidden">
|
||||
{playlist.coverUrl ? (
|
||||
<img src={playlist.coverUrl} className="w-full h-full object-cover" alt="" />
|
||||
) : (
|
||||
<Music size={18} />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-left overflow-hidden">
|
||||
<div className="font-medium text-zinc-900 dark:text-white truncate">{playlist.name}</div>
|
||||
<div className="text-xs text-zinc-500">{playlist.song_count || playlist.songIds?.length || 0} songs</div>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,312 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Song } from '../types';
|
||||
import { Heart, Share2, Play, MoreHorizontal, X, Copy, Wand2, MoreVertical, Download, Repeat, Video, Music, Link as LinkIcon, Sparkles, Globe, Lock, Trash2, Edit3, Layers } from 'lucide-react';
|
||||
import { songsApi } from '../services/api';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { SongDropdownMenu } from './SongDropdownMenu';
|
||||
import { ShareModal } from './ShareModal';
|
||||
import { AlbumCover } from './AlbumCover';
|
||||
|
||||
interface RightSidebarProps {
|
||||
song: Song | null;
|
||||
onClose?: () => void;
|
||||
onOpenVideo?: () => void;
|
||||
onReuse?: (song: Song) => void;
|
||||
onSongUpdate?: (song: Song) => void;
|
||||
onNavigateToProfile?: (username: string) => void;
|
||||
onNavigateToSong?: (songId: string) => void;
|
||||
isLiked?: boolean;
|
||||
onToggleLike?: (song: Song) => void;
|
||||
onDelete?: (song: Song) => void;
|
||||
onAddToPlaylist?: (song: Song) => void;
|
||||
}
|
||||
|
||||
export const RightSidebar: React.FC<RightSidebarProps> = ({ song, onClose, onOpenVideo, onReuse, onSongUpdate, onNavigateToProfile, onNavigateToSong, isLiked, onToggleLike, onDelete, onAddToPlaylist }) => {
|
||||
const { token, user } = useAuth();
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const [isOwner, setIsOwner] = useState(false);
|
||||
const [tagsExpanded, setTagsExpanded] = useState(false);
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
const [copiedStyle, setCopiedStyle] = useState(false);
|
||||
const [copiedLyrics, setCopiedLyrics] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (song) {
|
||||
setIsOwner(user?.id === song.userId);
|
||||
}
|
||||
}, [song, user]);
|
||||
|
||||
|
||||
if (!song) return (
|
||||
<div className="w-full h-full bg-zinc-50 dark:bg-suno-panel border-l border-zinc-200 dark:border-white/5 flex items-center justify-center text-zinc-400 dark:text-zinc-500 text-sm transition-colors duration-300">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Music size={40} className="text-zinc-300 dark:text-zinc-700" />
|
||||
<p>Select a song to view details</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full bg-zinc-50 dark:bg-suno-panel flex flex-col border-l border-zinc-200 dark:border-white/5 relative transition-colors duration-300">
|
||||
|
||||
{/* Header */}
|
||||
<div className="h-14 flex items-center justify-between px-4 border-b border-zinc-200 dark:border-white/5 flex-shrink-0 bg-zinc-50/50 dark:bg-suno-panel/50 backdrop-blur-md z-10">
|
||||
<span className="font-semibold text-sm text-zinc-900 dark:text-white">Song Details</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 hover:bg-zinc-200 dark:hover:bg-white/10 rounded-full text-zinc-500 dark:text-zinc-400 transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||
<div className="p-5 space-y-6">
|
||||
|
||||
{/* Cover Art */}
|
||||
<div className="group relative aspect-square w-full rounded-xl overflow-hidden shadow-2xl bg-zinc-200 dark:bg-zinc-800 ring-1 ring-black/5 dark:ring-white/10">
|
||||
{song.coverUrl ? (
|
||||
<img src={song.coverUrl} alt={song.title} className="w-full h-full object-cover transition-transform duration-700 group-hover:scale-105" onError={(e) => { e.currentTarget.style.display = 'none'; }} />
|
||||
) : null}
|
||||
{!song.coverUrl && <AlbumCover seed={song.id || song.title} size="full" className="w-full h-full" />}
|
||||
|
||||
{/* Overlay Gradient */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent opacity-60"></div>
|
||||
|
||||
<div className="absolute bottom-4 left-4 right-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-white">
|
||||
<Play size={16} fill="currentColor" />
|
||||
<span className="text-xs font-bold font-mono">{song.viewCount || 0}</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-black bg-white/90 px-1.5 py-0.5 rounded backdrop-blur-sm">
|
||||
{song.duration}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title & Artist Block */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-start gap-2">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<h2
|
||||
onClick={() => onNavigateToSong?.(song.id)}
|
||||
className="text-2xl font-bold text-zinc-900 dark:text-white leading-tight tracking-tight cursor-pointer hover:underline"
|
||||
>
|
||||
{song.title}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowMenu(!showMenu);
|
||||
}}
|
||||
className="text-zinc-400 hover:text-black dark:hover:text-white p-1"
|
||||
>
|
||||
<MoreVertical size={20} />
|
||||
</button>
|
||||
<SongDropdownMenu
|
||||
song={song}
|
||||
isOpen={showMenu}
|
||||
onClose={() => setShowMenu(false)}
|
||||
isOwner={isOwner}
|
||||
onCreateVideo={onOpenVideo}
|
||||
onReusePrompt={() => onReuse?.(song)}
|
||||
onDelete={() => onDelete?.(song)}
|
||||
onAddToPlaylist={() => onAddToPlaylist?.(song)}
|
||||
onShare={() => setShareModalOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-xs font-bold text-white shadow-sm ring-2 ring-white dark:ring-black">
|
||||
{song.creator ? song.creator[0].toUpperCase() : 'A'}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span
|
||||
onClick={() => song.creator && onNavigateToProfile?.(song.creator)}
|
||||
className="text-sm font-semibold text-zinc-900 dark:text-white hover:underline cursor-pointer"
|
||||
>
|
||||
{song.creator || 'Anonymous'}
|
||||
</span>
|
||||
<span className="text-xs text-zinc-500 dark:text-zinc-400">Created {new Date(song.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Actions */}
|
||||
<div className="flex items-center justify-between px-3 py-2.5 bg-zinc-200/80 dark:bg-black/40 backdrop-blur-sm rounded-2xl border border-zinc-300/50 dark:border-white/5">
|
||||
<button
|
||||
onClick={onOpenVideo}
|
||||
title="Create Video"
|
||||
className="p-3 text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white hover:bg-zinc-300/50 dark:hover:bg-white/10 rounded-xl transition-all duration-200"
|
||||
>
|
||||
<Video size={18} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!song?.audioUrl) return;
|
||||
const audioUrl = song.audioUrl.startsWith('http') ? song.audioUrl : `${window.location.origin}${song.audioUrl}`;
|
||||
window.open(`/editor?audioUrl=${encodeURIComponent(audioUrl)}`, '_blank');
|
||||
}}
|
||||
title="Open in Editor"
|
||||
className="p-3 text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white hover:bg-zinc-300/50 dark:hover:bg-white/10 rounded-xl transition-all duration-200"
|
||||
>
|
||||
<Edit3 size={18} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onReuse && onReuse(song)}
|
||||
title="Reuse Prompt"
|
||||
className="p-3 text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white hover:bg-zinc-300/50 dark:hover:bg-white/10 rounded-xl transition-all duration-200"
|
||||
>
|
||||
<Repeat size={18} strokeWidth={1.5} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!song?.audioUrl) return;
|
||||
const baseUrl = window.location.port === '3000'
|
||||
? `${window.location.protocol}//${window.location.hostname}:3001`
|
||||
: window.location.origin;
|
||||
const audioUrl = song.audioUrl.startsWith('http') ? song.audioUrl : `${baseUrl}${song.audioUrl}`;
|
||||
window.open(`${baseUrl}/demucs-web/?audioUrl=${encodeURIComponent(audioUrl)}`, '_blank');
|
||||
}}
|
||||
title="Extract Stems"
|
||||
className="p-3 text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white hover:bg-zinc-300/50 dark:hover:bg-white/10 rounded-xl transition-all duration-200"
|
||||
>
|
||||
<Layers size={18} strokeWidth={1.5} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Icon Actions Row */}
|
||||
<div className="flex items-center justify-between px-2 py-2">
|
||||
<div className="flex items-center gap-6">
|
||||
<ActionButton
|
||||
icon={<Heart size={22} fill={isLiked ? 'currentColor' : 'none'} />}
|
||||
label={String(song.likeCount || 0)}
|
||||
active={isLiked}
|
||||
onClick={() => onToggleLike?.(song)}
|
||||
/>
|
||||
<ActionButton icon={<Share2 size={22} />} onClick={() => setShareModalOpen(true)} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="p-2 text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors"
|
||||
title="Download Audio"
|
||||
onClick={async () => {
|
||||
if (!song.audioUrl) return;
|
||||
try {
|
||||
const response = await fetch(song.audioUrl);
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${song.title || 'song'}.mp3`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Download failed:', error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Download size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-zinc-200 dark:bg-white/5 w-full"></div>
|
||||
|
||||
{/* Tags / Style */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-bold text-zinc-500 dark:text-zinc-500 uppercase tracking-wider">Style & Tags</h3>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const allTags = song.tags && song.tags.length > 0
|
||||
? song.tags.join(', ')
|
||||
: song.style;
|
||||
navigator.clipboard.writeText(allTags);
|
||||
setCopiedStyle(true);
|
||||
setTimeout(() => setCopiedStyle(false), 2000);
|
||||
}}
|
||||
className={`flex items-center gap-1 text-[10px] font-medium transition-colors ${copiedStyle ? 'text-green-500' : 'text-zinc-500 hover:text-black dark:hover:text-white'}`}
|
||||
title="Copy all tags"
|
||||
>
|
||||
<Copy size={12} /> {copiedStyle ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setTagsExpanded(!tagsExpanded)}
|
||||
className={`flex flex-wrap gap-1.5 cursor-pointer relative ${!tagsExpanded ? 'max-h-[22px] overflow-hidden' : ''}`}
|
||||
>
|
||||
{Array.isArray(song.tags) && song.tags.length > 0 ? (
|
||||
song.tags.map(tag => (
|
||||
<span key={tag} className="px-2 py-0.5 bg-zinc-100 dark:bg-white/5 hover:bg-zinc-200 dark:hover:bg-white/10 border border-zinc-200 dark:border-white/10 rounded text-[11px] font-medium text-zinc-600 dark:text-zinc-300 transition-colors">
|
||||
{tag}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
song.style.split(',').map((tag, idx) => (
|
||||
<span key={idx} className="px-2 py-0.5 bg-zinc-100 dark:bg-white/5 hover:bg-zinc-200 dark:hover:bg-white/10 border border-zinc-200 dark:border-white/10 rounded text-[11px] font-medium text-zinc-600 dark:text-zinc-300 transition-colors">
|
||||
{tag.trim()}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
{!tagsExpanded && (
|
||||
<span className="absolute right-0 top-0 px-2 py-0.5 bg-zinc-200 dark:bg-zinc-700 rounded text-[11px] font-medium text-zinc-600 dark:text-zinc-300">
|
||||
+more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lyrics Section */}
|
||||
<div className="bg-white dark:bg-black/20 rounded-xl border border-zinc-200 dark:border-white/5 overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-zinc-100 dark:border-white/5 flex items-center justify-between bg-zinc-50 dark:bg-white/5">
|
||||
<h3 className="text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Lyrics</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (song.lyrics) {
|
||||
navigator.clipboard.writeText(song.lyrics);
|
||||
setCopiedLyrics(true);
|
||||
setTimeout(() => setCopiedLyrics(false), 2000);
|
||||
}
|
||||
}}
|
||||
className={`flex items-center gap-1 text-[10px] font-medium transition-colors ${copiedLyrics ? 'text-green-500' : 'text-zinc-500 hover:text-black dark:hover:text-white'}`}
|
||||
>
|
||||
<Copy size={12} /> {copiedLyrics ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 max-h-[300px] overflow-y-auto custom-scrollbar">
|
||||
<div className="text-sm text-zinc-700 dark:text-zinc-300 font-mono whitespace-pre-wrap leading-relaxed opacity-90">
|
||||
{song.lyrics || <div className="text-zinc-400 dark:text-zinc-600 italic text-center py-8">Instrumental<br /><span className="text-xs not-italic">No lyrics generated</span></div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{song && (
|
||||
<ShareModal
|
||||
isOpen={shareModalOpen}
|
||||
onClose={() => setShareModalOpen(false)}
|
||||
song={song}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ActionButton: React.FC<{ icon: React.ReactNode; label?: string; active?: boolean; onClick?: () => void }> = ({ icon, label, active, onClick }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-1.5 ${active ? 'text-pink-600 dark:text-pink-500' : 'text-zinc-400'} hover:text-black dark:hover:text-white transition-colors`}
|
||||
>
|
||||
{icon}
|
||||
{label && <span className="text-xs font-semibold">{label}</span>}
|
||||
</button>
|
||||
);
|
||||
@@ -0,0 +1,611 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Search, Play, Pause, Heart, ChevronRight, ChevronLeft, Copy, Check, X, Loader2 } from 'lucide-react';
|
||||
import { Song, Playlist } from '../types';
|
||||
import { songsApi, usersApi, playlistsApi, searchApi, UserProfile, getAudioUrl } from '../services/api';
|
||||
|
||||
interface SearchPageProps {
|
||||
onPlaySong?: (song: Song, list?: Song[]) => void;
|
||||
currentSong?: Song | null;
|
||||
isPlaying?: boolean;
|
||||
onNavigateToProfile?: (username: string) => void;
|
||||
onNavigateToSong?: (songId: string) => void;
|
||||
onNavigateToPlaylist?: (playlistId: string) => void;
|
||||
}
|
||||
|
||||
const GENRES = [
|
||||
'Pop', 'Rock', 'Electronic', 'Hip Hop', 'Country', 'Latin', 'Heavy Metal', 'Disco',
|
||||
'K-Pop', 'EDM', 'R&B', 'Indie', 'Folk', 'Funk', 'Jazz', 'Alternative Pop',
|
||||
'House', 'Afrobeats', 'Reggaeton', 'Rap', 'Blues', 'Gospel', 'Reggae',
|
||||
'Synthwave', 'J-Pop', 'Punk', 'Soul', 'Techno', 'Classical', 'Bossa Nova',
|
||||
'Ska', 'Bluegrass', 'Indie Surf', 'Lo-Fi Beats', 'Trap', 'Grunge', 'Chillhop',
|
||||
'New Wave', 'Drum And Bass', 'Acoustic Cover', 'Cinematic Dubstep', 'Modern Bollywood',
|
||||
'Opera', 'Ambient', 'Focus', 'A Capella', 'Meditation', 'Sleep'
|
||||
];
|
||||
|
||||
const MAX_RESULTS = 20;
|
||||
|
||||
interface ExtendedSong extends Song {
|
||||
creator_avatar?: string | null;
|
||||
}
|
||||
|
||||
export const SearchPage: React.FC<SearchPageProps> = ({
|
||||
onPlaySong,
|
||||
currentSong,
|
||||
isPlaying,
|
||||
onNavigateToProfile,
|
||||
onNavigateToSong,
|
||||
onNavigateToPlaylist,
|
||||
}) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [featuredSongs, setFeaturedSongs] = useState<ExtendedSong[]>([]);
|
||||
const [featuredCreators, setFeaturedCreators] = useState<Array<UserProfile & { song_count?: number }>>([]);
|
||||
const [featuredPlaylists, setFeaturedPlaylists] = useState<Array<Playlist & { creator?: string; creator_avatar?: string; song_count?: number }>>([]);
|
||||
const [searchResults, setSearchResults] = useState<{
|
||||
songs: ExtendedSong[];
|
||||
creators: Array<UserProfile & { song_count?: number }>;
|
||||
playlists: Array<Playlist & { creator?: string; creator_avatar?: string; song_count?: number }>;
|
||||
} | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [copiedTag, setCopiedTag] = useState<string | null>(null);
|
||||
|
||||
const songsScrollRef = useRef<HTMLDivElement>(null);
|
||||
const creatorsScrollRef = useRef<HTMLDivElement>(null);
|
||||
const playlistsScrollRef = useRef<HTMLDivElement>(null);
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadFeaturedContent();
|
||||
}, []);
|
||||
|
||||
const transformSong = (s: any): ExtendedSong => ({
|
||||
...s,
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
lyrics: s.lyrics || '',
|
||||
style: s.style || s.caption || '',
|
||||
coverUrl: s.cover_url || s.coverUrl || `https://picsum.photos/seed/${s.id}/400/400`,
|
||||
duration: s.duration ? (typeof s.duration === 'string' ? s.duration : `${Math.floor(s.duration / 60)}:${String(Math.floor(s.duration % 60)).padStart(2, '0')}`) : '0:00',
|
||||
createdAt: new Date(s.created_at || s.createdAt),
|
||||
tags: s.tags || [],
|
||||
audioUrl: getAudioUrl(s.audio_url || s.audioUrl, s.id),
|
||||
isPublic: s.is_public ?? s.isPublic,
|
||||
likeCount: s.like_count || s.likeCount || 0,
|
||||
viewCount: s.view_count || s.viewCount || 0,
|
||||
creator: s.creator,
|
||||
creator_avatar: s.creator_avatar || s.creatorAvatar || null,
|
||||
});
|
||||
|
||||
// Shuffle array randomly
|
||||
const shuffleArray = <T,>(array: T[]): T[] => {
|
||||
const shuffled = [...array];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
return shuffled;
|
||||
};
|
||||
|
||||
const loadFeaturedContent = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [songsRes, creatorsRes, playlistsRes] = await Promise.allSettled([
|
||||
songsApi.getFeaturedSongs(),
|
||||
usersApi.getFeaturedCreators().catch(() => ({ creators: [] })),
|
||||
playlistsApi.getFeaturedPlaylists().catch(() => ({ playlists: [] })),
|
||||
]);
|
||||
|
||||
if (songsRes.status === 'fulfilled') {
|
||||
const songs = songsRes.value.songs.map(transformSong);
|
||||
setFeaturedSongs(shuffleArray(songs).slice(0, MAX_RESULTS));
|
||||
}
|
||||
|
||||
if (creatorsRes.status === 'fulfilled' && creatorsRes.value.creators?.length > 0) {
|
||||
setFeaturedCreators(creatorsRes.value.creators.slice(0, MAX_RESULTS));
|
||||
} else if (songsRes.status === 'fulfilled' && songsRes.value.songs?.length > 0) {
|
||||
const uniqueCreators = new Map<string, UserProfile & { song_count?: number }>();
|
||||
songsRes.value.songs.forEach((song: any) => {
|
||||
if (song.creator && !uniqueCreators.has(song.creator)) {
|
||||
uniqueCreators.set(song.creator, {
|
||||
id: song.user_id || song.userId || song.creator,
|
||||
username: song.creator,
|
||||
email: '',
|
||||
created_at: song.created_at || song.createdAt,
|
||||
avatar_url: song.creator_avatar || song.creatorAvatar || null,
|
||||
});
|
||||
}
|
||||
});
|
||||
setFeaturedCreators(Array.from(uniqueCreators.values()).slice(0, MAX_RESULTS));
|
||||
}
|
||||
|
||||
if (playlistsRes.status === 'fulfilled') {
|
||||
setFeaturedPlaylists((playlistsRes.value.playlists || []).slice(0, MAX_RESULTS));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load featured content:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const performSearch = useCallback(async (query: string) => {
|
||||
if (!query.trim()) {
|
||||
setSearchResults(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearching(true);
|
||||
try {
|
||||
const results = await searchApi.search(query);
|
||||
setSearchResults({
|
||||
songs: (results.songs || []).slice(0, MAX_RESULTS).map(transformSong),
|
||||
creators: (results.creators || []).slice(0, MAX_RESULTS),
|
||||
playlists: (results.playlists || []).slice(0, MAX_RESULTS),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
setSearchResults({ songs: [], creators: [], playlists: [] });
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchQuery(value);
|
||||
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (!value.trim()) {
|
||||
setSearchResults(null);
|
||||
return;
|
||||
}
|
||||
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
performSearch(value);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const handleGenreClick = (genre: string) => {
|
||||
setSearchQuery(genre);
|
||||
performSearch(genre);
|
||||
};
|
||||
|
||||
const handleCopyTag = (tag: string) => {
|
||||
navigator.clipboard.writeText(tag);
|
||||
setCopiedTag(tag);
|
||||
setTimeout(() => setCopiedTag(null), 2000);
|
||||
};
|
||||
|
||||
const formatNumber = (count: number | undefined): string => {
|
||||
if (!count) return '0';
|
||||
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
|
||||
if (count >= 1000) return `${(count / 1000).toFixed(1)}K`;
|
||||
return count.toString();
|
||||
};
|
||||
|
||||
|
||||
const scroll = (ref: React.RefObject<HTMLDivElement | null>, direction: 'left' | 'right') => {
|
||||
if (ref.current) {
|
||||
const scrollAmount = 400;
|
||||
ref.current.scrollBy({
|
||||
left: direction === 'left' ? -scrollAmount : scrollAmount,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const displaySongs = searchResults?.songs || featuredSongs;
|
||||
const displayCreators = searchResults?.creators || featuredCreators;
|
||||
const displayPlaylists = searchResults?.playlists || featuredPlaylists;
|
||||
const isSearching = searchQuery.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-zinc-50 dark:bg-[#0a0a0a] h-full overflow-y-auto custom-scrollbar">
|
||||
<div className="max-w-[1400px] mx-auto px-6 py-6">
|
||||
{/* Search Input */}
|
||||
<div className="mb-8">
|
||||
<div className="relative max-w-3xl">
|
||||
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-400" size={20} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search for songs, playlists, creators, or genres"
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
className="w-full h-11 pl-12 pr-12 bg-white dark:bg-zinc-900/80 border border-zinc-200 dark:border-white/10 rounded-full text-zinc-900 dark:text-white placeholder-zinc-400 focus:outline-none focus:border-pink-500 dark:focus:border-pink-500 focus:ring-2 focus:ring-pink-500/20 transition-all"
|
||||
/>
|
||||
{searching ? (
|
||||
<Loader2 className="absolute right-4 top-1/2 -translate-y-1/2 text-pink-500 animate-spin" size={18} />
|
||||
) : searchQuery && (
|
||||
<button
|
||||
onClick={() => { setSearchQuery(''); setSearchResults(null); }}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600 dark:hover:text-white"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Songs Section */}
|
||||
<section className="mb-10">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">
|
||||
{isSearching ? `Songs matching "${searchQuery}"` : 'Featured Songs'}
|
||||
{isSearching && displaySongs.length > 0 && (
|
||||
<span className="ml-2 text-sm font-normal text-zinc-500">({displaySongs.length})</span>
|
||||
)}
|
||||
</h2>
|
||||
{!isSearching && displaySongs.length > 4 && (
|
||||
<button
|
||||
onClick={() => scroll(songsScrollRef, 'right')}
|
||||
className="p-1.5 rounded-full hover:bg-zinc-100 dark:hover:bg-white/5 text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="animate-pulse">
|
||||
<div className="bg-zinc-200 dark:bg-zinc-800 rounded-lg h-[72px]" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : displaySongs.length > 0 ? (
|
||||
<div
|
||||
ref={songsScrollRef}
|
||||
className={isSearching
|
||||
? "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3"
|
||||
: "grid grid-cols-2 lg:grid-cols-4 gap-3 auto-rows-max"
|
||||
}
|
||||
>
|
||||
{displaySongs.slice(0, isSearching ? MAX_RESULTS : 8).map((song) => (
|
||||
<FeaturedSongCard
|
||||
key={song.id}
|
||||
song={song}
|
||||
isPlaying={currentSong?.id === song.id && isPlaying}
|
||||
onPlay={() => onPlaySong?.(song, displaySongs)}
|
||||
onNavigateToProfile={onNavigateToProfile}
|
||||
onCopyTag={handleCopyTag}
|
||||
copiedTag={copiedTag}
|
||||
formatNumber={formatNumber}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : isSearching ? (
|
||||
<div className="text-center py-8 text-zinc-500 text-sm">
|
||||
No songs found matching "{searchQuery}"
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{/* Creators Section */}
|
||||
<section className="mb-10">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">
|
||||
{isSearching ? `Creators matching "${searchQuery}"` : 'Featured Creators'}
|
||||
{isSearching && displayCreators.length > 0 && (
|
||||
<span className="ml-2 text-sm font-normal text-zinc-500">({displayCreators.length})</span>
|
||||
)}
|
||||
</h2>
|
||||
{!isSearching && displayCreators.length > 6 && (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => scroll(creatorsScrollRef, 'left')}
|
||||
className="p-1.5 rounded-full hover:bg-zinc-100 dark:hover:bg-white/5 text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => scroll(creatorsScrollRef, 'right')}
|
||||
className="p-1.5 rounded-full hover:bg-zinc-100 dark:hover:bg-white/5 text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex gap-5 overflow-x-auto pb-2">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="flex-shrink-0 w-[110px] animate-pulse">
|
||||
<div className="w-[90px] h-[90px] mx-auto rounded-full bg-zinc-200 dark:bg-zinc-800 mb-2" />
|
||||
<div className="h-4 bg-zinc-200 dark:bg-zinc-800 rounded mx-2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : displayCreators.length > 0 ? (
|
||||
<div
|
||||
ref={creatorsScrollRef}
|
||||
className={isSearching
|
||||
? "grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-4"
|
||||
: "flex gap-5 overflow-x-auto pb-2 scrollbar-hide"
|
||||
}
|
||||
style={isSearching ? {} : { scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{displayCreators.map((creator) => (
|
||||
<CreatorCard
|
||||
key={creator.id}
|
||||
creator={creator}
|
||||
onNavigateToProfile={onNavigateToProfile}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-zinc-500 text-sm">
|
||||
{isSearching ? `No creators found matching "${searchQuery}"` : 'No creators yet. Be the first to share your music!'}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Playlists Section */}
|
||||
<section className="mb-10">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">
|
||||
{isSearching ? `Playlists matching "${searchQuery}"` : 'Featured Playlists'}
|
||||
{isSearching && displayPlaylists.length > 0 && (
|
||||
<span className="ml-2 text-sm font-normal text-zinc-500">({displayPlaylists.length})</span>
|
||||
)}
|
||||
</h2>
|
||||
{!isSearching && displayPlaylists.length > 5 && (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => scroll(playlistsScrollRef, 'left')}
|
||||
className="p-1.5 rounded-full hover:bg-zinc-100 dark:hover:bg-white/5 text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => scroll(playlistsScrollRef, 'right')}
|
||||
className="p-1.5 rounded-full hover:bg-zinc-100 dark:hover:bg-white/5 text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex gap-4 overflow-x-auto pb-2">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="flex-shrink-0 w-[140px] animate-pulse">
|
||||
<div className="aspect-square rounded-lg bg-zinc-200 dark:bg-zinc-800 mb-2" />
|
||||
<div className="h-4 bg-zinc-200 dark:bg-zinc-800 rounded mb-1" />
|
||||
<div className="h-3 bg-zinc-200 dark:bg-zinc-800 rounded w-2/3" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : displayPlaylists.length > 0 ? (
|
||||
<div
|
||||
ref={playlistsScrollRef}
|
||||
className={isSearching
|
||||
? "grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4"
|
||||
: "flex gap-4 overflow-x-auto pb-2 scrollbar-hide"
|
||||
}
|
||||
style={isSearching ? {} : { scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
{displayPlaylists.map((playlist) => (
|
||||
<PlaylistCard
|
||||
key={playlist.id}
|
||||
playlist={playlist}
|
||||
onNavigateToPlaylist={onNavigateToPlaylist}
|
||||
onNavigateToProfile={onNavigateToProfile}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-zinc-500 text-sm">
|
||||
{isSearching ? `No playlists found matching "${searchQuery}"` : 'No public playlists yet. Create one and share your favorites!'}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Genres */}
|
||||
<section className="mb-10">
|
||||
<h2 className="text-lg font-bold text-zinc-900 dark:text-white mb-4">Genres</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{GENRES.map((genre) => (
|
||||
<button
|
||||
key={genre}
|
||||
onClick={() => handleGenreClick(genre)}
|
||||
className={`px-3 py-1.5 border rounded-full text-sm transition-all duration-200 group flex items-center gap-1.5 ${
|
||||
searchQuery === genre
|
||||
? 'bg-pink-500 border-pink-500 text-white'
|
||||
: 'bg-zinc-100 dark:bg-zinc-800/60 border-zinc-200 dark:border-white/5 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700/60 hover:border-pink-500/30 hover:text-pink-600 dark:hover:text-pink-400'
|
||||
}`}
|
||||
>
|
||||
{genre}
|
||||
<Copy
|
||||
size={12}
|
||||
className={`opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer ${searchQuery === genre ? 'text-white/70' : ''}`}
|
||||
onClick={(e) => { e.stopPropagation(); handleCopyTag(genre); }}
|
||||
/>
|
||||
{copiedTag === genre && <Check size={12} className="text-green-500" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface FeaturedSongCardProps {
|
||||
song: ExtendedSong;
|
||||
isPlaying?: boolean;
|
||||
onPlay: () => void;
|
||||
onNavigateToProfile?: (username: string) => void;
|
||||
onCopyTag: (tag: string) => void;
|
||||
copiedTag: string | null;
|
||||
formatNumber: (n: number | undefined) => string;
|
||||
}
|
||||
|
||||
const FeaturedSongCard: React.FC<FeaturedSongCardProps> = ({
|
||||
song,
|
||||
isPlaying,
|
||||
onPlay,
|
||||
onNavigateToProfile,
|
||||
onCopyTag,
|
||||
copiedTag,
|
||||
formatNumber,
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const tags = song.style?.split(',').map(t => t.trim()).filter(Boolean).slice(0, 2) || [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-3 p-2 bg-white dark:bg-zinc-900/40 rounded-xl border border-zinc-100 dark:border-white/5 hover:border-pink-500/30 hover:bg-zinc-50 dark:hover:bg-zinc-800/40 transition-all cursor-pointer group"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="relative w-12 h-12 rounded-lg overflow-hidden flex-shrink-0" onClick={onPlay}>
|
||||
<img
|
||||
src={song.coverUrl}
|
||||
alt={song.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className={`absolute inset-0 bg-black/50 flex items-center justify-center transition-opacity ${isHovered || isPlaying ? 'opacity-100' : 'opacity-0'}`}>
|
||||
{isPlaying ? (
|
||||
<Pause size={16} className="text-white" fill="white" />
|
||||
) : (
|
||||
<Play size={16} className="text-white ml-0.5" fill="white" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 mb-0.5">
|
||||
<span className="font-semibold text-zinc-900 dark:text-white text-sm truncate max-w-[140px]">{song.title}</span>
|
||||
{song.isPublic !== false && (
|
||||
<span className="flex-shrink-0 text-[8px] font-bold text-white bg-gradient-to-r from-pink-500 to-purple-500 px-1 py-0.5 rounded">
|
||||
v5
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-500 dark:text-zinc-400 truncate mb-1">
|
||||
{tags.map((tag, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={(e) => { e.stopPropagation(); onCopyTag(tag); }}
|
||||
className="hover:text-pink-500 dark:hover:text-pink-400 transition-colors"
|
||||
>
|
||||
{tag}{i < tags.length - 1 ? ', ' : ''}
|
||||
{copiedTag === tag && <Check size={10} className="inline ml-0.5 text-green-500" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[10px] text-zinc-400">
|
||||
{song.creator && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onNavigateToProfile?.(song.creator!); }}
|
||||
className="flex items-center gap-1 hover:text-pink-500 transition-colors max-w-[80px]"
|
||||
>
|
||||
{song.creator_avatar ? (
|
||||
<img
|
||||
src={song.creator_avatar}
|
||||
alt={song.creator}
|
||||
className="w-3.5 h-3.5 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-3.5 h-3.5 rounded-full bg-gradient-to-br from-pink-500 to-purple-500 flex items-center justify-center text-[7px] text-white font-bold flex-shrink-0">
|
||||
{song.creator.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="truncate">{song.creator}</span>
|
||||
</button>
|
||||
)}
|
||||
<span className="flex items-center gap-0.5 flex-shrink-0">
|
||||
<Play size={9} /> {formatNumber(song.viewCount)}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5 flex-shrink-0">
|
||||
<Heart size={9} /> {formatNumber(song.likeCount)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface CreatorCardProps {
|
||||
creator: UserProfile & { song_count?: number };
|
||||
onNavigateToProfile?: (username: string) => void;
|
||||
}
|
||||
|
||||
const CreatorCard: React.FC<CreatorCardProps> = ({
|
||||
creator,
|
||||
onNavigateToProfile,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className="flex-shrink-0 w-[110px] text-center cursor-pointer group"
|
||||
onClick={() => onNavigateToProfile?.(creator.username)}
|
||||
>
|
||||
<div className="w-[90px] h-[90px] mx-auto rounded-full overflow-hidden mb-2 ring-2 ring-transparent group-hover:ring-pink-500 transition-all shadow-lg">
|
||||
<img
|
||||
src={creator.avatar_url || `https://api.dicebear.com/7.x/avataaars/svg?seed=${creator.username}`}
|
||||
alt={creator.username}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="font-semibold text-zinc-900 dark:text-white text-sm truncate group-hover:text-pink-500 transition-colors px-1">
|
||||
{creator.username}
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-500 truncate px-1">@{creator.username.toLowerCase().replace(/\s/g, '')}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface PlaylistCardProps {
|
||||
playlist: Playlist & { creator?: string; creator_avatar?: string; song_count?: number };
|
||||
onNavigateToPlaylist?: (playlistId: string) => void;
|
||||
onNavigateToProfile?: (username: string) => void;
|
||||
}
|
||||
|
||||
const PlaylistCard: React.FC<PlaylistCardProps> = ({
|
||||
playlist,
|
||||
onNavigateToPlaylist,
|
||||
onNavigateToProfile,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className="flex-shrink-0 w-[140px] cursor-pointer group"
|
||||
onClick={() => onNavigateToPlaylist?.(playlist.id)}
|
||||
>
|
||||
<div className="aspect-square rounded-lg overflow-hidden mb-2 shadow-md relative bg-zinc-200 dark:bg-zinc-800">
|
||||
<img
|
||||
src={playlist.cover_url || `https://picsum.photos/seed/${playlist.id}/400/400`}
|
||||
alt={playlist.name}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
<div className="font-semibold text-zinc-900 dark:text-white text-sm truncate group-hover:text-pink-500 transition-colors">
|
||||
{playlist.name}
|
||||
</div>
|
||||
<div className="text-[11px] text-zinc-500 mb-1">{playlist.song_count || 0} songs</div>
|
||||
{playlist.creator && (
|
||||
<div
|
||||
className="flex items-center gap-1.5 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNavigateToProfile?.(playlist.creator!);
|
||||
}}
|
||||
>
|
||||
<div className="w-4 h-4 rounded-full overflow-hidden flex-shrink-0 bg-zinc-300 dark:bg-zinc-700">
|
||||
<img
|
||||
src={playlist.creator_avatar || `https://api.dicebear.com/7.x/avataaars/svg?seed=${playlist.creator}`}
|
||||
alt={playlist.creator}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[11px] text-zinc-500 hover:text-pink-500 transition-colors truncate">
|
||||
{playlist.creator}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,182 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, User as UserIcon, Palette, Info, Edit3, ExternalLink } from 'lucide-react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { EditProfileModal } from './EditProfileModal';
|
||||
|
||||
interface SettingsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
theme: 'light' | 'dark';
|
||||
onToggleTheme: () => void;
|
||||
onNavigateToProfile?: (username: string) => void;
|
||||
}
|
||||
|
||||
export const SettingsModal: React.FC<SettingsModalProps> = ({ isOpen, onClose, theme, onToggleTheme, onNavigateToProfile }) => {
|
||||
const { user } = useAuth();
|
||||
const [isEditProfileOpen, setIsEditProfileOpen] = useState(false);
|
||||
|
||||
if (!isOpen || !user) {
|
||||
if (isEditProfileOpen && user) {
|
||||
return (
|
||||
<EditProfileModal
|
||||
isOpen={isEditProfileOpen}
|
||||
onClose={() => setIsEditProfileOpen(false)}
|
||||
onSaved={() => setIsEditProfileOpen(false)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" onClick={onClose}>
|
||||
<div
|
||||
className="bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-zinc-200 dark:border-white/5">
|
||||
<h2 className="text-2xl font-bold text-zinc-900 dark:text-white">Settings</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-zinc-100 dark:hover:bg-white/5 rounded-full transition-colors"
|
||||
>
|
||||
<X size={20} className="text-zinc-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-8">
|
||||
{/* User Profile Section */}
|
||||
<div className="bg-zinc-50 dark:bg-zinc-800/50 rounded-xl p-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-2xl font-bold text-white shadow-lg overflow-hidden">
|
||||
{user.avatar_url ? (
|
||||
<img src={user.avatar_url} alt={user.username} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
user.username[0].toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-bold text-zinc-900 dark:text-white">@{user.username}</h3>
|
||||
<p className="text-xs text-zinc-400 dark:text-zinc-500 mt-1">
|
||||
Member since {new Date(user.createdAt).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
onClose();
|
||||
setIsEditProfileOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700 transition-colors"
|
||||
>
|
||||
<Edit3 size={16} />
|
||||
Edit Profile
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onClose();
|
||||
onNavigateToProfile?.(user.username);
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-zinc-200 dark:bg-zinc-700 text-zinc-900 dark:text-white rounded-lg text-sm font-medium hover:bg-zinc-300 dark:hover:bg-zinc-600 transition-colors"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
View Profile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Account Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-zinc-900 dark:text-white">
|
||||
<UserIcon size={20} />
|
||||
<h3 className="font-semibold">Account</h3>
|
||||
</div>
|
||||
<div className="pl-7 space-y-3">
|
||||
<div>
|
||||
<label className="text-sm text-zinc-500 dark:text-zinc-400">Username</label>
|
||||
<p className="text-zinc-900 dark:text-white font-medium">@{user.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-zinc-900 dark:text-white">
|
||||
<Palette size={20} />
|
||||
<h3 className="font-semibold">Appearance</h3>
|
||||
</div>
|
||||
<div className="pl-7 space-y-3">
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={theme === 'dark' ? onToggleTheme : undefined}
|
||||
className={`flex-1 py-3 px-4 rounded-lg border-2 font-medium transition-colors ${theme === 'light'
|
||||
? 'border-indigo-500 bg-indigo-50 text-indigo-700'
|
||||
: 'border-zinc-300 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-600'
|
||||
}`}
|
||||
>
|
||||
Light
|
||||
</button>
|
||||
<button
|
||||
onClick={theme === 'light' ? onToggleTheme : undefined}
|
||||
className={`flex-1 py-3 px-4 rounded-lg border-2 font-medium transition-colors ${theme === 'dark'
|
||||
? 'border-indigo-500 bg-indigo-950 text-indigo-300'
|
||||
: 'border-zinc-300 dark:border-zinc-700 hover:border-zinc-400 dark:hover:border-zinc-600'
|
||||
}`}
|
||||
>
|
||||
Dark
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* About Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-zinc-900 dark:text-white">
|
||||
<Info size={20} />
|
||||
<h3 className="font-semibold">About</h3>
|
||||
</div>
|
||||
<div className="pl-7 space-y-3 text-sm text-zinc-600 dark:text-zinc-400">
|
||||
<p>Version 1.0.0</p>
|
||||
<p>ACE-Step UI - Local AI Music Generator</p>
|
||||
<p className="text-xs text-zinc-400 dark:text-zinc-500 mt-2">
|
||||
Powered by ACE-Step 1.5. Open source and free to use.
|
||||
</p>
|
||||
<div className="pt-3 border-t border-zinc-200 dark:border-zinc-700/50 mt-4">
|
||||
<p className="text-zinc-900 dark:text-white font-medium mb-2">Created by Ambsd</p>
|
||||
<a
|
||||
href="https://x.com/AmbsdOP"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-black dark:bg-white text-white dark:text-black rounded-lg text-sm font-medium hover:bg-zinc-800 dark:hover:bg-zinc-200 transition-colors"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
Follow @AmbsdOP
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-zinc-200 dark:border-white/5 p-6 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 bg-zinc-900 dark:bg-white text-white dark:text-black font-semibold rounded-lg hover:bg-zinc-800 dark:hover:bg-zinc-200 transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EditProfileModal
|
||||
isOpen={isEditProfileOpen}
|
||||
onClose={() => setIsEditProfileOpen(false)}
|
||||
onSaved={() => setIsEditProfileOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,226 @@
|
||||
import React, { useState } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { X, Link, Check } from 'lucide-react';
|
||||
import { Song } from '../types';
|
||||
|
||||
interface ShareModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
song: Song;
|
||||
}
|
||||
|
||||
const XIcon = () => (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const RedditIcon = () => (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const FacebookIcon = () => (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const WhatsAppIcon = () => (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const TelegramIcon = () => (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const LinkedInIcon = () => (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const EmailIcon = () => (
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="2" y="4" width="20" height="16" rx="2" />
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const ShareModal: React.FC<ShareModalProps> = ({ isOpen, onClose, song }) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const shareUrl = `${window.location.origin}/song/${song.id}`;
|
||||
|
||||
// Platform-specific share text for better engagement
|
||||
const defaultShareText = `🎵 "${song.title}" ${song.style ? `(${song.style})` : ''} - Made with ACE-Step UI`;
|
||||
const twitterText = `🔥 Just created "${song.title}" with ACE-Step UI - local AI music generation! ${song.style ? `#${song.style.replace(/\s+/g, '')}` : ''} #AIMusic #ACEStep`;
|
||||
const redditTitle = `[AI Music] ${song.title} - ${song.style || 'Original'} | Created with ACE-Step UI`;
|
||||
const whatsAppText = `🎧 Listen to this AI-generated song!\n\n"${song.title}" by ${song.creator || 'Unknown Artist'}\n${song.style ? `Genre: ${song.style}` : ''}\n\nMade with ACE-Step UI - free and open source!`;
|
||||
const telegramText = `🎵 "${song.title}" by ${song.creator || 'Unknown Artist'}\n${song.style ? `🎸 ${song.style}` : ''}\n\n🤖 Made with ACE-Step UI`;
|
||||
const linkedInText = `Check out this AI-generated music: "${song.title}" - Created locally with ACE-Step. #AIMusic #MusicTech #OpenSource`;
|
||||
|
||||
const handleShareX = () => {
|
||||
const url = `https://twitter.com/intent/tweet?text=${encodeURIComponent(twitterText)}&url=${encodeURIComponent(shareUrl)}`;
|
||||
window.open(url, '_blank', 'width=550,height=420');
|
||||
};
|
||||
|
||||
const handleShareReddit = () => {
|
||||
const url = `https://reddit.com/submit?url=${encodeURIComponent(shareUrl)}&title=${encodeURIComponent(redditTitle)}`;
|
||||
window.open(url, '_blank', 'width=800,height=600');
|
||||
};
|
||||
|
||||
const handleShareFacebook = () => {
|
||||
const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}"e=${encodeURIComponent(defaultShareText)}`;
|
||||
window.open(url, '_blank', 'width=550,height=420');
|
||||
};
|
||||
|
||||
const handleShareWhatsApp = () => {
|
||||
const url = `https://wa.me/?text=${encodeURIComponent(`${whatsAppText}\n\n${shareUrl}`)}`;
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
const handleShareTelegram = () => {
|
||||
const url = `https://t.me/share/url?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(telegramText)}`;
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
const handleShareLinkedIn = () => {
|
||||
const url = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`;
|
||||
window.open(url, '_blank', 'width=550,height=420');
|
||||
};
|
||||
|
||||
const handleShareEmail = () => {
|
||||
const subject = encodeURIComponent(`🎵 Check out this AI song: ${song.title}`);
|
||||
const body = encodeURIComponent(`Hey!\n\nI created this AI-generated song and thought you'd love it:\n\n"${song.title}" by ${song.creator || 'Unknown Artist'}\n${song.style ? `Genre: ${song.style}` : ''}\n\n🎧 Listen here: ${shareUrl}\n\n🤖 Made with ACE-Step UI - free and open source local AI music generation!`);
|
||||
window.location.href = `mailto:?subject=${subject}&body=${body}`;
|
||||
};
|
||||
|
||||
const handleCopyLink = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = shareUrl;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const modalContent = (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 dark:bg-black/80 backdrop-blur-sm p-4"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div className="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-white/10 rounded-xl w-full max-w-sm p-6 shadow-2xl animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-lg font-bold text-zinc-900 dark:text-white">Share Song</h2>
|
||||
<button onClick={onClose} className="text-zinc-400 hover:text-zinc-900 dark:hover:text-white">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mb-6 p-3 bg-zinc-50 dark:bg-black/30 rounded-lg">
|
||||
<img
|
||||
src={song.coverUrl}
|
||||
alt={song.title}
|
||||
className="w-12 h-12 rounded object-cover"
|
||||
/>
|
||||
<div className="overflow-hidden">
|
||||
<div className="font-medium text-zinc-900 dark:text-white truncate">{song.title}</div>
|
||||
<div className="text-sm text-zinc-500 truncate">{song.style}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-3 mb-4">
|
||||
<button
|
||||
onClick={handleShareX}
|
||||
className="flex flex-col items-center gap-1.5 p-3 rounded-lg bg-black text-white hover:bg-zinc-800 transition-colors"
|
||||
title="Share on X"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="text-xs font-medium">X</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleShareFacebook}
|
||||
className="flex flex-col items-center gap-1.5 p-3 rounded-lg bg-[#1877F2] text-white hover:bg-[#166FE5] transition-colors"
|
||||
title="Share on Facebook"
|
||||
>
|
||||
<FacebookIcon />
|
||||
<span className="text-xs font-medium">Facebook</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleShareWhatsApp}
|
||||
className="flex flex-col items-center gap-1.5 p-3 rounded-lg bg-[#25D366] text-white hover:bg-[#22C55E] transition-colors"
|
||||
title="Share on WhatsApp"
|
||||
>
|
||||
<WhatsAppIcon />
|
||||
<span className="text-xs font-medium">WhatsApp</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleShareTelegram}
|
||||
className="flex flex-col items-center gap-1.5 p-3 rounded-lg bg-[#0088CC] text-white hover:bg-[#0077B5] transition-colors"
|
||||
title="Share on Telegram"
|
||||
>
|
||||
<TelegramIcon />
|
||||
<span className="text-xs font-medium">Telegram</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleShareReddit}
|
||||
className="flex flex-col items-center gap-1.5 p-3 rounded-lg bg-[#FF4500] text-white hover:bg-[#FF5722] transition-colors"
|
||||
title="Share on Reddit"
|
||||
>
|
||||
<RedditIcon />
|
||||
<span className="text-xs font-medium">Reddit</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleShareLinkedIn}
|
||||
className="flex flex-col items-center gap-1.5 p-3 rounded-lg bg-[#0A66C2] text-white hover:bg-[#004182] transition-colors"
|
||||
title="Share on LinkedIn"
|
||||
>
|
||||
<LinkedInIcon />
|
||||
<span className="text-xs font-medium">LinkedIn</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleShareEmail}
|
||||
className="flex flex-col items-center gap-1.5 p-3 rounded-lg bg-zinc-600 dark:bg-zinc-700 text-white hover:bg-zinc-700 dark:hover:bg-zinc-600 transition-colors"
|
||||
title="Share via Email"
|
||||
>
|
||||
<EmailIcon />
|
||||
<span className="text-xs font-medium">Email</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleCopyLink}
|
||||
className="flex flex-col items-center gap-1.5 p-3 rounded-lg border border-zinc-200 dark:border-white/10 text-zinc-900 dark:text-white hover:bg-zinc-50 dark:hover:bg-white/5 transition-colors"
|
||||
title="Copy Link"
|
||||
>
|
||||
{copied ? <Check size={20} className="text-green-500" /> : <Link size={20} />}
|
||||
<span className="text-xs font-medium">{copied ? 'Copied!' : 'Copy'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return ReactDOM.createPortal(modalContent, document.body);
|
||||
};
|
||||
@@ -0,0 +1,125 @@
|
||||
import React from 'react';
|
||||
import { Library, Disc, Search, User, LogIn, LogOut, Sun, Moon } from 'lucide-react';
|
||||
import { View } from '../types';
|
||||
|
||||
interface SidebarProps {
|
||||
currentView: View;
|
||||
onNavigate: (view: View) => void;
|
||||
theme: 'light' | 'dark';
|
||||
onToggleTheme: () => void;
|
||||
user?: { username: string; isAdmin?: boolean; avatar_url?: string } | null;
|
||||
onLogin?: () => void;
|
||||
onLogout?: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
}
|
||||
|
||||
export const Sidebar: React.FC<SidebarProps> = ({
|
||||
currentView,
|
||||
onNavigate,
|
||||
theme,
|
||||
onToggleTheme,
|
||||
user,
|
||||
onLogin,
|
||||
onLogout,
|
||||
onOpenSettings,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-white dark:bg-suno-sidebar border-r border-zinc-200 dark:border-white/5 flex-shrink-0 w-[72px] items-center py-4 z-30 transition-colors duration-300 overflow-y-auto scrollbar-hide">
|
||||
{/* Logo */}
|
||||
<div
|
||||
className="w-10 h-10 rounded-full bg-gradient-to-br from-pink-500 to-purple-600 flex items-center justify-center mb-8 cursor-pointer shadow-lg hover:scale-105 transition-transform"
|
||||
onClick={() => onNavigate('create')}
|
||||
title="ACE-Step UI"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className="w-5 h-5 text-white">
|
||||
<path d="M12 2L2 7L12 12L22 7L12 2Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M2 17L12 22L22 17" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M2 12L12 17L22 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 flex flex-col gap-4 w-full px-3">
|
||||
<NavItem
|
||||
icon={<Disc size={24} />}
|
||||
label="Create"
|
||||
active={currentView === 'create'}
|
||||
onClick={() => onNavigate('create')}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<Library size={24} />}
|
||||
label="Library"
|
||||
active={currentView === 'library'}
|
||||
onClick={() => onNavigate('library')}
|
||||
/>
|
||||
<NavItem
|
||||
icon={<Search size={24} />}
|
||||
label="Search"
|
||||
active={currentView === 'search'}
|
||||
onClick={() => onNavigate('search')}
|
||||
/>
|
||||
|
||||
<div className="mt-auto flex flex-col gap-4">
|
||||
<button
|
||||
onClick={onToggleTheme}
|
||||
className="w-10 h-10 rounded-full hover:bg-zinc-100 dark:hover:bg-white/10 flex items-center justify-center text-zinc-500 dark:text-zinc-400 hover:text-black dark:hover:text-white transition-colors mx-auto"
|
||||
title={theme === 'dark' ? 'Light Mode' : 'Dark Mode'}
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={20} /> : <Moon size={20} />}
|
||||
</button>
|
||||
|
||||
{user ? (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div
|
||||
onClick={onOpenSettings}
|
||||
className="w-8 h-8 rounded-full bg-gradient-to-br from-pink-500 to-purple-600 flex items-center justify-center text-white text-xs font-bold cursor-pointer border border-white/20 hover:scale-110 transition-transform overflow-hidden"
|
||||
title={`${user.username} - Settings`}
|
||||
>
|
||||
{user.avatar_url ? (
|
||||
<img src={user.avatar_url} alt={user.username} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
user.username.charAt(0).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={onLogout}
|
||||
className="w-8 h-8 rounded-full hover:bg-red-500/20 flex items-center justify-center text-zinc-500 hover:text-red-500 transition-colors"
|
||||
title="Sign Out"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={onLogin}
|
||||
className="w-10 h-10 rounded-full hover:bg-zinc-100 dark:hover:bg-white/10 flex items-center justify-center text-zinc-500 dark:text-zinc-400 hover:text-pink-500 transition-colors mx-auto"
|
||||
title="Sign In"
|
||||
>
|
||||
<LogIn size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface NavItemProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
active?: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const NavItem: React.FC<NavItemProps> = ({ icon, label, active, onClick }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`
|
||||
w-full aspect-square rounded-xl flex flex-col items-center justify-center gap-1 transition-all duration-200 group relative
|
||||
${active ? 'bg-zinc-100 dark:bg-white/10 text-black dark:text-white' : 'text-zinc-500 hover:text-black dark:hover:text-white hover:bg-zinc-100 dark:hover:bg-white/5'}
|
||||
`}
|
||||
title={label}
|
||||
>
|
||||
{active && <div className="absolute left-0 top-1/2 -translate-y-1/2 h-8 w-1 bg-pink-500 rounded-r-full"></div>}
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
@@ -0,0 +1,227 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Song } from '../types';
|
||||
import {
|
||||
Video,
|
||||
Edit3,
|
||||
Layers,
|
||||
Repeat,
|
||||
ListPlus,
|
||||
Download,
|
||||
Trash2,
|
||||
Share2
|
||||
} from 'lucide-react';
|
||||
|
||||
interface SongDropdownMenuProps {
|
||||
song: Song;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
isOwner?: boolean;
|
||||
position?: 'left' | 'right';
|
||||
direction?: 'up' | 'down';
|
||||
onCreateVideo?: () => void;
|
||||
onEditAudio?: () => void;
|
||||
onExtractStems?: () => void;
|
||||
onReusePrompt?: () => void;
|
||||
onAddToPlaylist?: () => void;
|
||||
onDownload?: () => void;
|
||||
onShare?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
interface MenuItemProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick?: () => void;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const MenuItem: React.FC<MenuItemProps> = ({ icon, label, onClick, danger, disabled }) => (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`w-full px-3 py-2 text-left text-sm flex items-center gap-3 transition-colors
|
||||
${danger
|
||||
? 'text-red-400 hover:bg-red-500/10 hover:text-red-300'
|
||||
: 'text-zinc-300 hover:bg-white/5 hover:text-white'}
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
|
||||
`}
|
||||
>
|
||||
<span className="w-4 h-4 flex items-center justify-center opacity-70">{icon}</span>
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
const MenuDivider: React.FC = () => (
|
||||
<div className="h-px bg-white/10 my-1 mx-2" />
|
||||
);
|
||||
|
||||
export const SongDropdownMenu: React.FC<SongDropdownMenuProps> = ({
|
||||
song,
|
||||
isOpen,
|
||||
onClose,
|
||||
isOwner = false,
|
||||
position = 'right',
|
||||
direction = 'down',
|
||||
onCreateVideo,
|
||||
onEditAudio,
|
||||
onExtractStems,
|
||||
onReusePrompt,
|
||||
onAddToPlaylist,
|
||||
onDownload,
|
||||
onShare,
|
||||
onDelete
|
||||
}) => {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleAction = (action?: () => void) => {
|
||||
if (action) {
|
||||
action();
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleEditAudio = () => {
|
||||
if (!song.audioUrl) return;
|
||||
const audioUrl = song.audioUrl.startsWith('http')
|
||||
? song.audioUrl
|
||||
: `${window.location.origin}${song.audioUrl}`;
|
||||
window.open(`/editor?audioUrl=${encodeURIComponent(audioUrl)}`, '_blank');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleExtractStems = () => {
|
||||
if (!song.audioUrl) return;
|
||||
const baseUrl = window.location.port === '3000'
|
||||
? `${window.location.protocol}//${window.location.hostname}:3001`
|
||||
: window.location.origin;
|
||||
const audioUrl = song.audioUrl.startsWith('http')
|
||||
? song.audioUrl
|
||||
: `${baseUrl}${song.audioUrl}`;
|
||||
window.open(`${baseUrl}/demucs-web/?audioUrl=${encodeURIComponent(audioUrl)}`, '_blank');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!song.audioUrl) return;
|
||||
try {
|
||||
// Fetch as blob to handle cross-origin
|
||||
const response = await fetch(song.audioUrl);
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${song.title || 'song'}.mp3`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// Clean up blob URL
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Download failed:', error);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const positionClasses = position === 'left' ? 'left-0' : 'right-0';
|
||||
const directionClasses = direction === 'up'
|
||||
? 'bottom-full mb-2'
|
||||
: 'top-full mt-2';
|
||||
const animationClasses = direction === 'up'
|
||||
? 'animate-in fade-in slide-in-from-bottom-2'
|
||||
: 'animate-in fade-in slide-in-from-top-2';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className={`absolute ${positionClasses} ${directionClasses} w-52
|
||||
bg-zinc-900 rounded-xl shadow-2xl border border-white/10 py-1.5 z-50
|
||||
${animationClasses} duration-150`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Creative Actions */}
|
||||
<MenuItem
|
||||
icon={<Video size={14} />}
|
||||
label="Create Video"
|
||||
onClick={() => handleAction(onCreateVideo)}
|
||||
/>
|
||||
{isOwner && (
|
||||
<MenuItem
|
||||
icon={<Edit3 size={14} />}
|
||||
label="Edit Audio"
|
||||
onClick={onEditAudio ? () => handleAction(onEditAudio) : handleEditAudio}
|
||||
/>
|
||||
)}
|
||||
<MenuItem
|
||||
icon={<Layers size={14} />}
|
||||
label="Extract Stems"
|
||||
onClick={onExtractStems ? () => handleAction(onExtractStems) : handleExtractStems}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={<Repeat size={14} />}
|
||||
label="Reuse Prompt"
|
||||
onClick={() => handleAction(onReusePrompt)}
|
||||
/>
|
||||
|
||||
<MenuDivider />
|
||||
|
||||
{/* Library Actions */}
|
||||
<MenuItem
|
||||
icon={<ListPlus size={14} />}
|
||||
label="Add to Playlist"
|
||||
onClick={() => handleAction(onAddToPlaylist)}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={<Download size={14} />}
|
||||
label="Download"
|
||||
onClick={onDownload ? () => handleAction(onDownload) : handleDownload}
|
||||
/>
|
||||
<MenuItem
|
||||
icon={<Share2 size={14} />}
|
||||
label="Share"
|
||||
onClick={() => handleAction(onShare)}
|
||||
/>
|
||||
|
||||
{/* Owner-only Actions */}
|
||||
{isOwner && (
|
||||
<>
|
||||
<MenuDivider />
|
||||
<MenuItem
|
||||
icon={<Trash2 size={14} />}
|
||||
label="Delete Song"
|
||||
onClick={() => handleAction(onDelete)}
|
||||
danger
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,456 @@
|
||||
import React, { useState, useMemo, useRef, useEffect } from 'react';
|
||||
import { Song } from '../types';
|
||||
import { Play, MoreHorizontal, Heart, ThumbsDown, ListPlus, Pause, Search, Filter, Check, Globe, Lock, Loader2, ThumbsUp, Share2, Video, Info, Clock } from 'lucide-react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { SongDropdownMenu } from './SongDropdownMenu';
|
||||
import { ShareModal } from './ShareModal';
|
||||
import { AlbumCover } from './AlbumCover';
|
||||
|
||||
interface SongListProps {
|
||||
songs: Song[];
|
||||
currentSong: Song | null;
|
||||
selectedSong: Song | null;
|
||||
likedSongIds: Set<string>;
|
||||
isPlaying: boolean;
|
||||
onPlay: (song: Song) => void;
|
||||
onSelect: (song: Song) => void;
|
||||
onToggleLike: (songId: string) => void;
|
||||
onAddToPlaylist: (song: Song) => void;
|
||||
onOpenVideo?: (song: Song) => void;
|
||||
onShowDetails?: (song: Song) => void;
|
||||
onNavigateToProfile?: (username: string) => void;
|
||||
onReusePrompt?: (song: Song) => void;
|
||||
onDelete?: (song: Song) => void;
|
||||
}
|
||||
|
||||
// ... existing code ...
|
||||
|
||||
|
||||
|
||||
// Define Filter Types
|
||||
type FilterType = 'liked' | 'public' | 'private' | 'generating';
|
||||
|
||||
const FILTERS: { id: FilterType; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: 'liked', label: 'Liked', icon: <ThumbsUp size={16} /> },
|
||||
{ id: 'public', label: 'Public', icon: <Globe size={16} /> },
|
||||
{ id: 'private', label: 'Private', icon: <Lock size={16} /> },
|
||||
{ id: 'generating', label: 'Generating', icon: <Loader2 size={16} /> },
|
||||
];
|
||||
|
||||
export const SongList: React.FC<SongListProps> = ({
|
||||
songs,
|
||||
currentSong,
|
||||
selectedSong,
|
||||
likedSongIds,
|
||||
isPlaying,
|
||||
onPlay,
|
||||
onSelect,
|
||||
onToggleLike,
|
||||
onAddToPlaylist,
|
||||
onOpenVideo,
|
||||
onShowDetails,
|
||||
onNavigateToProfile,
|
||||
onReusePrompt,
|
||||
onDelete
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeFilters, setActiveFilters] = useState<Set<FilterType>>(new Set());
|
||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||
const filterRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close filter dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (filterRef.current && !filterRef.current.contains(event.target as Node)) {
|
||||
setIsFilterOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const toggleFilter = (filterId: FilterType) => {
|
||||
setActiveFilters(prev => {
|
||||
const newFilters = new Set(prev);
|
||||
if (newFilters.has(filterId)) {
|
||||
newFilters.delete(filterId);
|
||||
} else {
|
||||
newFilters.add(filterId);
|
||||
}
|
||||
return newFilters;
|
||||
});
|
||||
};
|
||||
|
||||
const filteredSongs = useMemo(() => {
|
||||
return songs.filter(song => {
|
||||
// 1. Search Logic
|
||||
const matchesSearch =
|
||||
song.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
song.style.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
song.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
|
||||
if (!matchesSearch) return false;
|
||||
|
||||
// 2. Filter Logic
|
||||
if (activeFilters.size === 0) return true;
|
||||
|
||||
if (activeFilters.has('liked') && !likedSongIds.has(song.id)) return false;
|
||||
if (activeFilters.has('public') && !song.isPublic) return false;
|
||||
if (activeFilters.has('private') && song.isPublic) return false;
|
||||
if (activeFilters.has('generating') && !song.isGenerating) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [songs, searchQuery, activeFilters, likedSongIds]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 bg-white dark:bg-black h-full overflow-y-auto custom-scrollbar p-6 pb-32 transition-colors duration-300">
|
||||
<div className="max-w-5xl mx-auto w-full"> {/* Container constraint */}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-6 mb-8">
|
||||
<div className="flex items-center gap-2 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
<span className="hover:text-black dark:hover:text-white cursor-pointer transition-colors">Workspaces</span>
|
||||
<span className="text-zinc-400 dark:text-zinc-600">›</span>
|
||||
<span className="text-zinc-900 dark:text-white font-medium">My Workspace</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative group flex-1">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search your songs..."
|
||||
className="w-full bg-zinc-100 dark:bg-[#121214] border border-zinc-200 dark:border-white/10 rounded-lg pl-10 pr-4 py-2.5 text-sm text-zinc-900 dark:text-white focus:outline-none focus:border-zinc-400 dark:focus:border-white/20 placeholder-zinc-500 dark:placeholder-zinc-600 transition-colors"
|
||||
/>
|
||||
<Search className="w-4 h-4 text-zinc-500 absolute left-3 top-3 group-focus-within:text-black dark:group-focus-within:text-white transition-colors" />
|
||||
</div>
|
||||
|
||||
<div className="relative" ref={filterRef}>
|
||||
<button
|
||||
onClick={() => setIsFilterOpen(!isFilterOpen)}
|
||||
className={`
|
||||
border text-xs font-bold px-4 py-2.5 rounded-lg flex items-center gap-2 transition-all select-none
|
||||
${isFilterOpen || activeFilters.size > 0
|
||||
? 'bg-zinc-900 dark:bg-white text-white dark:text-black border-transparent'
|
||||
: 'bg-zinc-100 dark:bg-[#121214] hover:bg-zinc-200 dark:hover:bg-white/5 border-zinc-200 dark:border-white/10 text-zinc-700 dark:text-white'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Filter size={14} fill={activeFilters.size > 0 ? "currentColor" : "none"} />
|
||||
<span>Filters {activeFilters.size > 0 && `(${activeFilters.size})`}</span>
|
||||
</button>
|
||||
|
||||
{/* Filter Dropdown */}
|
||||
{isFilterOpen && (
|
||||
<div className="absolute right-0 top-full mt-2 w-56 bg-white dark:bg-[#18181b] border border-zinc-200 dark:border-white/10 rounded-xl shadow-2xl overflow-hidden py-1 z-50 animate-in fade-in zoom-in-95 duration-100 origin-top-right">
|
||||
<div className="px-3 py-2 text-[10px] font-bold text-zinc-500 uppercase tracking-wider">
|
||||
Refine By
|
||||
</div>
|
||||
{FILTERS.map(filter => (
|
||||
<button
|
||||
key={filter.id}
|
||||
onClick={() => toggleFilter(filter.id)}
|
||||
className="w-full text-left px-4 py-2.5 flex items-center justify-between hover:bg-zinc-100 dark:hover:bg-white/5 transition-colors group"
|
||||
>
|
||||
<div className="flex items-center gap-3 text-sm font-medium text-zinc-700 dark:text-zinc-300 group-hover:text-black dark:group-hover:text-white">
|
||||
<span className="text-zinc-400 dark:text-zinc-500 group-hover:text-zinc-600 dark:group-hover:text-zinc-300 transition-colors">
|
||||
{filter.icon}
|
||||
</span>
|
||||
{filter.label}
|
||||
</div>
|
||||
<div className={`
|
||||
w-4 h-4 rounded border flex items-center justify-center transition-all
|
||||
${activeFilters.has(filter.id)
|
||||
? 'bg-pink-600 border-pink-600'
|
||||
: 'border-zinc-300 dark:border-zinc-600 group-hover:border-zinc-400 dark:group-hover:border-zinc-500'
|
||||
}
|
||||
`}>
|
||||
{activeFilters.has(filter.id) && <Check size={10} className="text-white" strokeWidth={4} />}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="space-y-2"> {/* Reduced vertical spacing */}
|
||||
{filteredSongs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-zinc-500 space-y-4 border border-dashed border-zinc-200 dark:border-white/5 rounded-2xl bg-zinc-50 dark:bg-white/[0.02]">
|
||||
<div className="w-16 h-16 rounded-full bg-zinc-100 dark:bg-white/5 flex items-center justify-center">
|
||||
<Filter size={32} />
|
||||
</div>
|
||||
<p className="font-medium">No songs match your filters.</p>
|
||||
<button
|
||||
onClick={() => { setActiveFilters(new Set()); setSearchQuery(''); }}
|
||||
className="text-pink-600 dark:text-pink-500 text-sm font-bold hover:underline"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
filteredSongs.map((song) => (
|
||||
<SongItem
|
||||
key={song.id}
|
||||
song={song}
|
||||
isCurrent={currentSong?.id === song.id}
|
||||
isSelected={selectedSong?.id === song.id}
|
||||
isLiked={likedSongIds.has(song.id)}
|
||||
isPlaying={isPlaying}
|
||||
isOwner={user?.id === song.userId}
|
||||
onPlay={() => onPlay(song)}
|
||||
onSelect={() => onSelect(song)}
|
||||
onToggleLike={() => onToggleLike(song.id)}
|
||||
onAddToPlaylist={() => onAddToPlaylist(song)}
|
||||
onOpenVideo={() => onOpenVideo && onOpenVideo(song)}
|
||||
onShowDetails={() => onShowDetails && onShowDetails(song)}
|
||||
onNavigateToProfile={onNavigateToProfile}
|
||||
onReusePrompt={() => onReusePrompt?.(song)}
|
||||
onDelete={() => onDelete?.(song)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div> {/* End container */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SongItemProps {
|
||||
song: Song;
|
||||
isCurrent: boolean;
|
||||
isSelected: boolean;
|
||||
isLiked: boolean;
|
||||
isPlaying: boolean;
|
||||
isOwner: boolean;
|
||||
onPlay: () => void;
|
||||
onSelect: () => void;
|
||||
onToggleLike: () => void;
|
||||
onAddToPlaylist: () => void;
|
||||
onOpenVideo?: () => void;
|
||||
onShowDetails?: () => void;
|
||||
onNavigateToProfile?: (username: string) => void;
|
||||
onReusePrompt?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
const SongItem: React.FC<SongItemProps> = ({
|
||||
song,
|
||||
isCurrent,
|
||||
isSelected,
|
||||
isLiked,
|
||||
isPlaying,
|
||||
isOwner,
|
||||
onPlay,
|
||||
onSelect,
|
||||
onToggleLike,
|
||||
onAddToPlaylist,
|
||||
onOpenVideo,
|
||||
onShowDetails,
|
||||
onNavigateToProfile,
|
||||
onReusePrompt,
|
||||
onDelete
|
||||
}) => {
|
||||
const [showDropdown, setShowDropdown] = useState(false);
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onClick={onSelect}
|
||||
className={`group flex items-center gap-4 p-2 rounded-lg hover:bg-zinc-100 dark:hover:bg-[#18181b] transition-all cursor-pointer border ${isSelected ? 'bg-zinc-100 dark:bg-[#18181b] border-zinc-200 dark:border-white/10' : 'border-transparent bg-transparent'}`}
|
||||
>
|
||||
|
||||
{/* Cover Art - Reduced size */}
|
||||
<div className="relative w-16 h-16 flex-shrink-0 rounded-md bg-zinc-200 dark:bg-zinc-800 overflow-hidden shadow-sm group/image">
|
||||
{/* Use gradient fallback if no coverUrl or image fails to load */}
|
||||
{(!song.coverUrl || imageError) ? (
|
||||
<AlbumCover seed={song.id || song.title} size="full" className={`w-full h-full ${song.isGenerating ? 'opacity-20 blur-sm' : 'opacity-100'}`} />
|
||||
) : (
|
||||
<img
|
||||
src={song.coverUrl}
|
||||
alt={song.title}
|
||||
className={`w-full h-full object-cover transition-opacity ${song.isGenerating ? 'opacity-20 blur-sm' : 'opacity-100'}`}
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{song.isGenerating ? (
|
||||
<div className="absolute inset-0 bg-black/40 flex flex-col items-center justify-center gap-1">
|
||||
{song.queuePosition ? (
|
||||
/* Queue indicator */
|
||||
<>
|
||||
<div className="w-8 h-8 rounded-full bg-amber-500/20 flex items-center justify-center">
|
||||
<Clock size={16} className="text-amber-400" />
|
||||
</div>
|
||||
<span className="text-[10px] font-medium text-amber-400">Queue #{song.queuePosition}</span>
|
||||
</>
|
||||
) : (
|
||||
/* Generating - Music Waveform Animation */
|
||||
<div className="flex items-end gap-1 h-6">
|
||||
<div className="w-1 bg-pink-500 rounded-full music-bar-anim" style={{ animationDelay: '0.0s' }}></div>
|
||||
<div className="w-1 bg-pink-500 rounded-full music-bar-anim" style={{ animationDelay: '0.2s' }}></div>
|
||||
<div className="w-1 bg-pink-500 rounded-full music-bar-anim" style={{ animationDelay: '0.4s' }}></div>
|
||||
<div className="w-1 bg-pink-500 rounded-full music-bar-anim" style={{ animationDelay: '0.1s' }}></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={`absolute inset-0 bg-black/40 flex items-center justify-center backdrop-blur-[1px] cursor-pointer transition-opacity duration-200 ${isCurrent ? 'opacity-100' : 'opacity-0 group-hover/image:opacity-100'}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onPlay();
|
||||
}}
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-white flex items-center justify-center shadow-lg transform transition-transform hover:scale-105">
|
||||
{isCurrent && isPlaying ? (
|
||||
<Pause fill="black" className="text-black w-5 h-5" />
|
||||
) : (
|
||||
<Play fill="black" className="text-black ml-1 w-5 h-5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0 flex flex-col justify-between py-1">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className={`font-bold text-lg truncate ${isCurrent ? 'text-pink-600 dark:text-pink-500' : 'text-zinc-900 dark:text-white'}`}>
|
||||
{song.title || (song.isGenerating ? (song.queuePosition ? "Queued..." : "Creating...") : "Untitled")}
|
||||
</h3>
|
||||
<span className="inline-flex items-center justify-center text-[9px] font-bold text-white bg-gradient-to-r from-pink-500 to-purple-500 px-1.5 py-0.5 rounded-sm shadow-sm">
|
||||
v1.5
|
||||
</span>
|
||||
{song.isPublic === false && (
|
||||
<Lock size={12} className="text-zinc-400 dark:text-zinc-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex items-center gap-1.5 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (song.creator && onNavigateToProfile) {
|
||||
onNavigateToProfile(song.creator);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="w-4 h-4 rounded-full bg-purple-500 text-[8px] flex items-center justify-center font-bold text-white">
|
||||
{(song.creator?.[0] || 'U').toUpperCase()}
|
||||
</div>
|
||||
<span className="text-xs font-medium text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-200 transition-colors hover:underline">
|
||||
{song.creator || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-500 line-clamp-2 pt-1 font-medium max-w-2xl">
|
||||
{song.style}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions Row - Hidden while generating */}
|
||||
{!song.isGenerating && (
|
||||
<div className="flex items-center gap-1 pt-2">
|
||||
<button
|
||||
className={`flex items-center gap-1 px-3 py-1.5 rounded-full hover:bg-white/5 transition-colors ${isLiked ? 'text-pink-600 dark:text-pink-500 bg-pink-100 dark:bg-pink-500/10' : 'text-zinc-400 hover:text-black dark:hover:text-white'}`}
|
||||
onClick={(e) => { e.stopPropagation(); onToggleLike(); }}
|
||||
>
|
||||
<ThumbsUp size={16} fill={isLiked ? "currentColor" : "none"} />
|
||||
{(song.likeCount || 0) > 0 && (
|
||||
<span className="text-xs font-bold">{song.likeCount}</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="p-2 rounded-full hover:bg-zinc-200 dark:hover:bg-white/5 text-zinc-400 hover:text-black dark:hover:text-white transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); }}
|
||||
>
|
||||
<ThumbsDown size={16} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="p-2 rounded-full hover:bg-zinc-200 dark:hover:bg-white/5 text-zinc-400 hover:text-black dark:hover:text-white transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); setShareModalOpen(true); }}
|
||||
title="Share"
|
||||
>
|
||||
<Share2 size={16} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="p-2 rounded-full hover:bg-zinc-200 dark:hover:bg-white/5 text-zinc-400 hover:text-black dark:hover:text-white transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); if (onOpenVideo) onOpenVideo(); }}
|
||||
title="Create Video"
|
||||
>
|
||||
<Video size={16} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="p-2 rounded-full hover:bg-zinc-200 dark:hover:bg-white/5 text-zinc-400 hover:text-black dark:hover:text-white transition-colors ml-auto"
|
||||
onClick={(e) => { e.stopPropagation(); onAddToPlaylist(); }}
|
||||
title="Add to Playlist"
|
||||
>
|
||||
<ListPlus size={16} />
|
||||
</button>
|
||||
|
||||
{/* Info Button - Visible only on small/medium screens where sidebar is hidden */}
|
||||
<button
|
||||
className="p-2 rounded-full hover:bg-zinc-200 dark:hover:bg-white/5 text-zinc-400 hover:text-black dark:hover:text-white transition-colors xl:hidden"
|
||||
onClick={(e) => { e.stopPropagation(); if (onShowDetails) onShowDetails(); }}
|
||||
title="Song Details"
|
||||
>
|
||||
<Info size={16} />
|
||||
</button>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
className="p-2 rounded-full hover:bg-zinc-200 dark:hover:bg-white/5 text-zinc-400 hover:text-black dark:hover:text-white transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowDropdown(!showDropdown);
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
</button>
|
||||
<SongDropdownMenu
|
||||
song={song}
|
||||
isOpen={showDropdown}
|
||||
onClose={() => setShowDropdown(false)}
|
||||
isOwner={isOwner}
|
||||
onCreateVideo={() => onOpenVideo?.(song)}
|
||||
onReusePrompt={() => onReusePrompt?.(song)}
|
||||
onAddToPlaylist={() => onAddToPlaylist?.(song)}
|
||||
onDelete={() => onDelete?.(song)}
|
||||
onShare={() => setShareModalOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<div className="text-xs font-mono text-zinc-500 dark:text-zinc-600 self-start pt-1">
|
||||
{song.isGenerating ? (
|
||||
<span className={song.queuePosition ? 'text-amber-500' : 'text-pink-500'}>
|
||||
{song.queuePosition ? `#${song.queuePosition}` : 'Creating...'}
|
||||
</span>
|
||||
) : song.duration}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ShareModal
|
||||
isOpen={shareModalOpen}
|
||||
onClose={() => setShareModalOpen(false)}
|
||||
song={song}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,312 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Song } from '../types';
|
||||
import { songsApi, getAudioUrl } from '../services/api';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { ArrowLeft, Play, Pause, Heart, Share2, MoreHorizontal, ThumbsDown, Music as MusicIcon, Edit3, Eye } from 'lucide-react';
|
||||
import { ShareModal } from './ShareModal';
|
||||
|
||||
interface SongProfileProps {
|
||||
songId: string;
|
||||
onBack: () => void;
|
||||
onPlay: (song: Song, list?: Song[]) => void;
|
||||
onNavigateToProfile: (username: string) => void;
|
||||
currentSong?: Song | null;
|
||||
isPlaying?: boolean;
|
||||
likedSongIds?: Set<string>;
|
||||
onToggleLike?: (songId: string) => void;
|
||||
}
|
||||
|
||||
const updateMetaTags = (song: Song) => {
|
||||
const baseUrl = window.location.origin;
|
||||
const songUrl = `${baseUrl}/song/${song.id}`;
|
||||
const title = `${song.title} by ${song.creator || 'Unknown Artist'} | ACE-Step UI`;
|
||||
const description = `Listen to "${song.title}" - ${song.style}. ${song.viewCount || 0} plays, ${song.likeCount || 0} likes. Create your own AI music with ACE-Step UI.`;
|
||||
|
||||
document.title = title;
|
||||
|
||||
const updateOrCreateMeta = (selector: string, attribute: string, value: string) => {
|
||||
let element = document.querySelector(selector) as HTMLMetaElement;
|
||||
if (!element) {
|
||||
element = document.createElement('meta');
|
||||
const [attr, attrValue] = selector.replace(/[\[\]'"]/g, '').split('=');
|
||||
if (attr === 'property') element.setAttribute('property', attrValue);
|
||||
else if (attr === 'name') element.setAttribute('name', attrValue);
|
||||
document.head.appendChild(element);
|
||||
}
|
||||
element.setAttribute(attribute, value);
|
||||
};
|
||||
|
||||
updateOrCreateMeta('meta[name="description"]', 'content', description);
|
||||
updateOrCreateMeta('meta[name="title"]', 'content', title);
|
||||
|
||||
updateOrCreateMeta('meta[property="og:type"]', 'content', 'music.song');
|
||||
updateOrCreateMeta('meta[property="og:url"]', 'content', songUrl);
|
||||
updateOrCreateMeta('meta[property="og:title"]', 'content', title);
|
||||
updateOrCreateMeta('meta[property="og:description"]', 'content', description);
|
||||
updateOrCreateMeta('meta[property="og:image"]', 'content', song.coverUrl);
|
||||
updateOrCreateMeta('meta[property="og:image:width"]', 'content', '400');
|
||||
updateOrCreateMeta('meta[property="og:image:height"]', 'content', '400');
|
||||
updateOrCreateMeta('meta[property="og:audio"]', 'content', song.audioUrl || '');
|
||||
updateOrCreateMeta('meta[property="og:audio:type"]', 'content', 'audio/mpeg');
|
||||
|
||||
updateOrCreateMeta('meta[name="twitter:card"]', 'content', 'summary_large_image');
|
||||
updateOrCreateMeta('meta[name="twitter:url"]', 'content', songUrl);
|
||||
updateOrCreateMeta('meta[name="twitter:title"]', 'content', title);
|
||||
updateOrCreateMeta('meta[name="twitter:description"]', 'content', description);
|
||||
updateOrCreateMeta('meta[name="twitter:image"]', 'content', song.coverUrl);
|
||||
|
||||
updateOrCreateMeta('meta[property="music:duration"]', 'content', String(song.duration || 0));
|
||||
updateOrCreateMeta('meta[property="music:musician"]', 'content', song.creator || 'Unknown Artist');
|
||||
};
|
||||
|
||||
const resetMetaTags = () => {
|
||||
document.title = 'ACE-Step UI - Local AI Music Generator';
|
||||
const defaultDescription = 'Create original music with AI locally. Generate songs in any style with custom lyrics and professional quality using ACE-Step.';
|
||||
const defaultImage = '/og-image.png';
|
||||
|
||||
const updateMeta = (selector: string, content: string) => {
|
||||
const element = document.querySelector(selector) as HTMLMetaElement;
|
||||
if (element) element.setAttribute('content', content);
|
||||
};
|
||||
|
||||
updateMeta('meta[name="description"]', defaultDescription);
|
||||
updateMeta('meta[property="og:title"]', 'ACE-Step UI - Local AI Music Generator');
|
||||
updateMeta('meta[property="og:description"]', defaultDescription);
|
||||
updateMeta('meta[property="og:image"]', defaultImage);
|
||||
updateMeta('meta[property="og:type"]', 'website');
|
||||
updateMeta('meta[name="twitter:title"]', 'ACE-Step UI - Local AI Music Generator');
|
||||
updateMeta('meta[name="twitter:description"]', defaultDescription);
|
||||
updateMeta('meta[name="twitter:image"]', defaultImage);
|
||||
};
|
||||
|
||||
export const SongProfile: React.FC<SongProfileProps> = ({ songId, onBack, onPlay, onNavigateToProfile, currentSong, isPlaying, likedSongIds = new Set(), onToggleLike }) => {
|
||||
const { user, token } = useAuth();
|
||||
const [song, setSong] = useState<Song | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [shareModalOpen, setShareModalOpen] = useState(false);
|
||||
|
||||
const isCurrentSong = song && currentSong?.id === song.id;
|
||||
const isCurrentlyPlaying = isCurrentSong && isPlaying;
|
||||
const isLiked = song ? likedSongIds.has(song.id) : false;
|
||||
|
||||
useEffect(() => {
|
||||
loadSongData();
|
||||
return () => resetMetaTags();
|
||||
}, [songId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (song) {
|
||||
updateMetaTags(song);
|
||||
}
|
||||
}, [song]);
|
||||
|
||||
const loadSongData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await songsApi.getFullSong(songId, token);
|
||||
|
||||
const transformedSong: Song = {
|
||||
id: response.song.id,
|
||||
title: response.song.title,
|
||||
lyrics: response.song.lyrics,
|
||||
style: response.song.style,
|
||||
coverUrl: `https://picsum.photos/seed/${response.song.id}/400/400`,
|
||||
duration: response.song.duration
|
||||
? `${Math.floor(response.song.duration / 60)}:${String(Math.floor(response.song.duration % 60)).padStart(2, '0')}`
|
||||
: '0:00',
|
||||
createdAt: new Date(response.song.created_at),
|
||||
tags: response.song.tags || [],
|
||||
audioUrl: getAudioUrl(response.song.audio_url, response.song.id),
|
||||
isPublic: response.song.is_public,
|
||||
likeCount: response.song.like_count || 0,
|
||||
viewCount: response.song.view_count || 0,
|
||||
userId: response.song.user_id,
|
||||
creator: response.song.creator,
|
||||
creator_avatar: response.song.creator_avatar,
|
||||
};
|
||||
|
||||
setSong(transformedSong);
|
||||
} catch (error) {
|
||||
console.error('Failed to load song:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full bg-zinc-50 dark:bg-black">
|
||||
<div className="text-zinc-500 dark:text-zinc-400 flex items-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-zinc-400 border-t-transparent rounded-full animate-spin" />
|
||||
Loading song...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!song) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 bg-zinc-50 dark:bg-black">
|
||||
<div className="text-zinc-500 dark:text-zinc-400">Song not found</div>
|
||||
<button onClick={onBack} className="px-4 py-2 bg-zinc-200 dark:bg-zinc-800 hover:bg-zinc-300 dark:hover:bg-zinc-700 rounded-lg text-zinc-900 dark:text-white transition-colors">
|
||||
Go Back
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col bg-zinc-50 dark:bg-black overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="border-b border-zinc-200 dark:border-zinc-800 px-4 md:px-6 py-4 flex-shrink-0">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white mb-4 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-zinc-900 dark:text-white mb-2">{song.title}</h1>
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div
|
||||
onClick={() => song.creator && onNavigateToProfile(song.creator)}
|
||||
className="flex items-center gap-2 cursor-pointer hover:underline"
|
||||
>
|
||||
<div className="w-6 h-6 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-xs font-bold text-white overflow-hidden">
|
||||
{song.creator_avatar ? (
|
||||
<img src={song.creator_avatar} alt={song.creator || 'Creator'} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
song.creator ? song.creator[0].toUpperCase() : 'A'
|
||||
)}
|
||||
</div>
|
||||
<span className="text-zinc-900 dark:text-white font-semibold">{song.creator || 'Anonymous'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{song.style.split(',').slice(0, 4).map((tag, i) => (
|
||||
<span key={i} className="px-2 py-1 bg-zinc-200 dark:bg-zinc-800 rounded text-xs text-zinc-600 dark:text-zinc-300">
|
||||
{tag.trim()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-zinc-500">
|
||||
{new Date(song.createdAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })} at {new Date(song.createdAt).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}
|
||||
{!song.isPublic && song.userId === user?.id && (
|
||||
<span className="ml-2 px-2 py-0.5 bg-zinc-200 dark:bg-zinc-800 rounded text-xs text-zinc-600 dark:text-zinc-400">Private</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Related Songs Tab - Hidden on mobile */}
|
||||
<div className="hidden md:flex items-center gap-2">
|
||||
<button className="px-4 py-2 bg-zinc-900 dark:bg-white text-white dark:text-black rounded-full text-sm font-semibold">
|
||||
Similar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => song.creator && onNavigateToProfile(song.creator)}
|
||||
className="px-4 py-2 text-zinc-500 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white text-sm font-semibold transition-colors"
|
||||
>
|
||||
By {song.creator || 'Artist'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-3xl mx-auto px-4 md:px-6 py-4 md:py-6">
|
||||
|
||||
{/* Left Column: Song Details */}
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
{/* Cover Art */}
|
||||
<div className="relative aspect-square max-w-xs md:max-w-sm mx-auto lg:mx-0 rounded-xl overflow-hidden shadow-2xl">
|
||||
<img src={song.coverUrl} alt={song.title} className={`w-full h-full object-cover transition-transform duration-500 ${isCurrentlyPlaying ? 'scale-105' : ''}`} />
|
||||
<button
|
||||
onClick={() => onPlay(song)}
|
||||
className={`absolute inset-0 transition-colors flex items-center justify-center group ${isCurrentSong ? 'bg-black/50' : 'bg-black/40 hover:bg-black/50'}`}
|
||||
>
|
||||
<div className="w-16 h-16 md:w-20 md:h-20 rounded-full bg-white group-hover:scale-110 transition-transform flex items-center justify-center shadow-xl">
|
||||
{isCurrentlyPlaying ? (
|
||||
<Pause size={28} className="text-black fill-black md:w-8 md:h-8" />
|
||||
) : (
|
||||
<Play size={28} className="text-black fill-black ml-1 md:w-8 md:h-8" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
{isCurrentlyPlaying && (
|
||||
<div className="absolute bottom-4 left-4 flex items-center gap-1">
|
||||
<span className="w-1.5 h-4 bg-pink-500 rounded-full animate-pulse" style={{ animationDelay: '0ms' }} />
|
||||
<span className="w-1.5 h-6 bg-pink-500 rounded-full animate-pulse" style={{ animationDelay: '150ms' }} />
|
||||
<span className="w-1.5 h-3 bg-pink-500 rounded-full animate-pulse" style={{ animationDelay: '300ms' }} />
|
||||
<span className="w-1.5 h-7 bg-pink-500 rounded-full animate-pulse" style={{ animationDelay: '450ms' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center justify-center lg:justify-start gap-2 md:gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 bg-zinc-200 dark:bg-zinc-900 px-3 py-2 rounded-full text-sm">
|
||||
<Eye size={16} className="text-zinc-600 dark:text-white" />
|
||||
<span className="text-zinc-900 dark:text-white font-semibold">{song.viewCount || 0}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onToggleLike?.(song.id)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-full text-sm transition-colors ${isLiked ? 'bg-pink-500 text-white' : 'bg-zinc-200 dark:bg-zinc-900 hover:bg-zinc-300 dark:hover:bg-zinc-800 text-zinc-900 dark:text-white'}`}
|
||||
>
|
||||
<Heart size={16} className={isLiked ? 'fill-current' : ''} />
|
||||
<span className="font-semibold">{song.likeCount || 0}</span>
|
||||
</button>
|
||||
{user?.id === song.userId && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!song.audioUrl) return;
|
||||
const audioUrl = song.audioUrl.startsWith('http') ? song.audioUrl : `${window.location.origin}${song.audioUrl}`;
|
||||
window.open(`/editor?audioUrl=${encodeURIComponent(audioUrl)}`, '_blank');
|
||||
}}
|
||||
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-700 px-3 py-2 rounded-full text-sm font-semibold transition-colors text-white"
|
||||
>
|
||||
<Edit3 size={16} />
|
||||
<span className="hidden md:inline">Edit</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShareModalOpen(true)}
|
||||
className="p-2 bg-zinc-200 dark:bg-zinc-900 hover:bg-zinc-300 dark:hover:bg-zinc-800 rounded-full transition-colors"
|
||||
>
|
||||
<Share2 size={16} className="text-zinc-700 dark:text-white" />
|
||||
</button>
|
||||
<button className="p-2 bg-zinc-200 dark:bg-zinc-900 hover:bg-zinc-300 dark:hover:bg-zinc-800 rounded-full transition-colors">
|
||||
<MoreHorizontal size={16} className="text-zinc-700 dark:text-white" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Lyrics */}
|
||||
{song.lyrics && (
|
||||
<div className="bg-white dark:bg-zinc-900/50 border border-zinc-200 dark:border-zinc-800 rounded-xl p-4">
|
||||
<h3 className="text-sm font-semibold text-zinc-900 dark:text-white mb-3">Lyrics</h3>
|
||||
<div className="text-sm text-zinc-700 dark:text-zinc-300 whitespace-pre-line leading-relaxed max-h-72 md:max-h-96 overflow-y-auto">
|
||||
{song.lyrics}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{song && (
|
||||
<ShareModal
|
||||
isOpen={shareModalOpen}
|
||||
onClose={() => setShareModalOpen(false)}
|
||||
song={song}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { CheckCircle, AlertCircle, X } from 'lucide-react';
|
||||
|
||||
export type ToastType = 'success' | 'error' | 'info';
|
||||
|
||||
interface ToastProps {
|
||||
message: string;
|
||||
type?: ToastType;
|
||||
isVisible: boolean;
|
||||
onClose: () => void;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export const Toast: React.FC<ToastProps> = ({
|
||||
message,
|
||||
type = 'success',
|
||||
isVisible,
|
||||
onClose,
|
||||
duration = 3000
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
if (isVisible && duration > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
onClose();
|
||||
}, duration);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isVisible, duration, onClose]);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
const bgColors = {
|
||||
success: 'bg-zinc-900 border-green-500/50 text-white',
|
||||
error: 'bg-zinc-900 border-red-500/50 text-white',
|
||||
info: 'bg-zinc-900 border-blue-500/50 text-white',
|
||||
};
|
||||
|
||||
const icons = {
|
||||
success: <CheckCircle className="text-green-500" size={20} />,
|
||||
error: <AlertCircle className="text-red-500" size={20} />,
|
||||
info: <AlertCircle className="text-blue-500" size={20} />,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`fixed top-6 left-1/2 -translate-x-1/2 z-[100] flex items-center gap-3 px-6 py-4 rounded-full shadow-2xl border ${bgColors[type]} animate-in slide-in-from-top-4 fade-in duration-300`}>
|
||||
{icons[type]}
|
||||
<span className="font-medium text-sm">{message}</span>
|
||||
<button onClick={onClose} className="ml-2 hover:opacity-70">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,726 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Song, Playlist } from '../types';
|
||||
import { usersApi, getAudioUrl, UserProfile as UserProfileType, songsApi } from '../services/api';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { ArrowLeft, Play, Pause, Heart, Eye, Users, Music as MusicIcon, ChevronRight, Share2, MoreHorizontal, Edit3, X, Camera, Image as ImageIcon, Upload, Loader2 } from 'lucide-react';
|
||||
|
||||
interface UserProfileProps {
|
||||
username: string;
|
||||
onBack: () => void;
|
||||
onPlaySong: (song: Song, list?: Song[]) => void;
|
||||
onNavigateToProfile: (username: string) => void;
|
||||
onNavigateToPlaylist?: (playlistId: string) => void;
|
||||
currentSong?: Song | null;
|
||||
isPlaying?: boolean;
|
||||
likedSongIds?: Set<string>;
|
||||
onToggleLike?: (songId: string) => void;
|
||||
}
|
||||
|
||||
export const UserProfile: React.FC<UserProfileProps> = ({ username, onBack, onPlaySong, onNavigateToProfile, onNavigateToPlaylist, currentSong, isPlaying, likedSongIds = new Set(), onToggleLike }) => {
|
||||
const { user: currentUser, token } = useAuth();
|
||||
const [profileUser, setProfileUser] = useState<UserProfileType | null>(null);
|
||||
const [publicSongs, setPublicSongs] = useState<Song[]>([]);
|
||||
const [publicPlaylists, setPublicPlaylists] = useState<Playlist[]>([]);
|
||||
const [songsTab, setSongsTab] = useState<'recent' | 'top'>('recent');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Edit State
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [editBio, setEditBio] = useState('');
|
||||
const [editAvatarUrl, setEditAvatarUrl] = useState('');
|
||||
const [editBannerUrl, setEditBannerUrl] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
const [bannerFile, setBannerFile] = useState<File | null>(null);
|
||||
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
|
||||
const [bannerPreview, setBannerPreview] = useState<string | null>(null);
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||
const [uploadingBanner, setUploadingBanner] = useState(false);
|
||||
const [avatarFailed, setAvatarFailed] = useState(false);
|
||||
const avatarInputRef = useRef<HTMLInputElement>(null);
|
||||
const bannerInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadUserProfile();
|
||||
}, [username]);
|
||||
|
||||
const loadUserProfile = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [profileRes, songsRes, playlistsRes] = await Promise.all([
|
||||
usersApi.getProfile(username, token),
|
||||
usersApi.getPublicSongs(username),
|
||||
usersApi.getPublicPlaylists(username)
|
||||
]);
|
||||
|
||||
setProfileUser(profileRes.user);
|
||||
setEditBio(profileRes.user.bio || '');
|
||||
setEditAvatarUrl(profileRes.user.avatar_url || '');
|
||||
setEditBannerUrl(profileRes.user.banner_url || '');
|
||||
|
||||
const transformedSongs: Song[] = songsRes.songs.map(s => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
lyrics: s.lyrics,
|
||||
style: s.style,
|
||||
coverUrl: `https://picsum.photos/seed/${s.id}/400/400`,
|
||||
duration: s.duration ? `${Math.floor(s.duration / 60)}:${String(Math.floor(s.duration % 60)).padStart(2, '0')}` : '0:00',
|
||||
createdAt: new Date(s.created_at),
|
||||
tags: s.tags || [],
|
||||
audioUrl: getAudioUrl(s.audio_url, s.id),
|
||||
isPublic: true,
|
||||
likeCount: s.like_count || 0,
|
||||
viewCount: s.view_count || 0,
|
||||
creator: s.creator,
|
||||
}));
|
||||
setPublicSongs(transformedSongs);
|
||||
setPublicPlaylists(playlistsRes.playlists || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load user profile:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setAvatarFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => setAvatarPreview(ev.target?.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBannerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setBannerFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => setBannerPreview(ev.target?.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
if (!token) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// Upload avatar if changed
|
||||
if (avatarFile) {
|
||||
setUploadingAvatar(true);
|
||||
const avatarRes = await usersApi.uploadAvatar(avatarFile, token);
|
||||
setEditAvatarUrl(avatarRes.url);
|
||||
setUploadingAvatar(false);
|
||||
}
|
||||
|
||||
// Upload banner if changed
|
||||
if (bannerFile) {
|
||||
setUploadingBanner(true);
|
||||
const bannerRes = await usersApi.uploadBanner(bannerFile, token);
|
||||
setEditBannerUrl(bannerRes.url);
|
||||
setUploadingBanner(false);
|
||||
}
|
||||
|
||||
// Update bio (and any URL-based avatar/banner if not using file upload)
|
||||
const updates: Record<string, string> = { bio: editBio };
|
||||
if (!avatarFile && editAvatarUrl !== profileUser.avatar_url) {
|
||||
updates.avatarUrl = editAvatarUrl;
|
||||
}
|
||||
if (!bannerFile && editBannerUrl !== profileUser.banner_url) {
|
||||
updates.bannerUrl = editBannerUrl;
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await usersApi.updateProfile(updates, token);
|
||||
}
|
||||
|
||||
setIsEditModalOpen(false);
|
||||
setAvatarFile(null);
|
||||
setBannerFile(null);
|
||||
setAvatarPreview(null);
|
||||
setBannerPreview(null);
|
||||
|
||||
// Reload to get fresh data
|
||||
loadUserProfile();
|
||||
} catch (error) {
|
||||
console.error('Failed to update profile:', error);
|
||||
alert('Failed to update profile');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
setUploadingAvatar(false);
|
||||
setUploadingBanner(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full bg-zinc-50 dark:bg-black">
|
||||
<div className="text-zinc-500 dark:text-zinc-400 gap-2 flex items-center">
|
||||
<div className="w-4 h-4 border-2 border-zinc-400 border-t-transparent rounded-full animate-spin"></div>
|
||||
Loading profile...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!profileUser) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-4 bg-zinc-50 dark:bg-black">
|
||||
<div className="text-zinc-500 dark:text-zinc-400">User not found</div>
|
||||
<button onClick={onBack} className="px-4 py-2 bg-zinc-200 dark:bg-zinc-800 hover:bg-zinc-300 dark:hover:bg-zinc-700 rounded-lg text-zinc-900 dark:text-white">
|
||||
Go Back
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalLikes = publicSongs.reduce((sum, song) => sum + (song.likeCount || 0), 0);
|
||||
const totalPlays = publicSongs.reduce((sum, song) => sum + (song.viewCount || 0), 0);
|
||||
const isOwner = currentUser?.id === profileUser.id;
|
||||
|
||||
// Generate random gradient for banner fallback
|
||||
const gradients = [
|
||||
'from-purple-600 via-pink-600 to-red-600',
|
||||
'from-blue-600 via-purple-600 to-pink-600',
|
||||
'from-green-600 via-teal-600 to-blue-600',
|
||||
'from-orange-600 via-red-600 to-pink-600',
|
||||
'from-indigo-600 via-purple-600 to-pink-600',
|
||||
];
|
||||
const bannerGradient = gradients[username.length % gradients.length];
|
||||
const primaryBadge = profileUser.badges?.[0];
|
||||
const badgeRing = primaryBadge?.color === 'yellow'
|
||||
? 'ring-yellow-400/80 shadow-yellow-500/30'
|
||||
: primaryBadge?.color === 'purple'
|
||||
? 'ring-purple-400/80 shadow-purple-500/30'
|
||||
: primaryBadge?.color === 'blue'
|
||||
? 'ring-blue-400/80 shadow-blue-500/30'
|
||||
: primaryBadge?.color === 'teal'
|
||||
? 'ring-teal-400/80 shadow-teal-500/30'
|
||||
: primaryBadge?.color === 'green'
|
||||
? 'ring-green-400/80 shadow-green-500/30'
|
||||
: primaryBadge?.color === 'orange'
|
||||
? 'ring-orange-400/80 shadow-orange-500/30'
|
||||
: primaryBadge?.color === 'pink'
|
||||
? 'ring-pink-400/80 shadow-pink-500/30'
|
||||
: 'ring-zinc-500/50 shadow-zinc-500/20';
|
||||
const paidPulse = profileUser.accountTier && profileUser.accountTier !== 'free'
|
||||
? 'group-hover/avatar:animate-[wiggle_0.6s_ease-in-out] group-hover/avatar:rotate-1'
|
||||
: '';
|
||||
const paidNameStyle = primaryBadge?.color === 'yellow'
|
||||
? 'bg-gradient-to-r from-yellow-300 via-amber-300 to-orange-400 text-transparent bg-clip-text drop-shadow-[0_2px_12px_rgba(251,191,36,0.45)]'
|
||||
: primaryBadge?.color === 'purple'
|
||||
? 'bg-gradient-to-r from-fuchsia-400 via-purple-500 to-indigo-400 text-transparent bg-clip-text drop-shadow-[0_2px_12px_rgba(168,85,247,0.45)]'
|
||||
: primaryBadge?.color === 'blue'
|
||||
? 'bg-gradient-to-r from-sky-400 via-blue-500 to-indigo-400 text-transparent bg-clip-text drop-shadow-[0_2px_12px_rgba(59,130,246,0.45)]'
|
||||
: primaryBadge?.color === 'teal'
|
||||
? 'bg-gradient-to-r from-teal-300 via-emerald-400 to-cyan-400 text-transparent bg-clip-text drop-shadow-[0_2px_12px_rgba(45,212,191,0.45)]'
|
||||
: primaryBadge?.color === 'orange'
|
||||
? 'bg-gradient-to-r from-orange-300 via-amber-400 to-yellow-300 text-transparent bg-clip-text drop-shadow-[0_2px_12px_rgba(251,146,60,0.4)]'
|
||||
: primaryBadge?.color === 'pink'
|
||||
? 'bg-gradient-to-r from-pink-400 via-rose-500 to-fuchsia-500 text-transparent bg-clip-text drop-shadow-[0_2px_12px_rgba(244,114,182,0.45)]'
|
||||
: '';
|
||||
|
||||
// Banner Style
|
||||
const bannerStyle = profileUser.banner_url
|
||||
? { backgroundImage: `url(${profileUser.banner_url})`, backgroundSize: 'cover', backgroundPosition: 'center' }
|
||||
: {};
|
||||
const bannerClass = profileUser.banner_url
|
||||
? `h-48 md:h-64 relative overflow-hidden bg-zinc-200 dark:bg-zinc-900`
|
||||
: `h-48 md:h-64 bg-gradient-to-r ${bannerGradient} relative overflow-hidden`;
|
||||
|
||||
const featuredSongs = publicSongs.slice(0, 6);
|
||||
const displaySongs = songsTab === 'recent' ? publicSongs : [...publicSongs].sort((a, b) => (b.likeCount || 0) - (a.likeCount || 0));
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col bg-zinc-50 dark:bg-black overflow-y-auto relative">
|
||||
{/* Hero Banner */}
|
||||
<div className="relative group/banner">
|
||||
{/* Background Banner */}
|
||||
<div className={bannerClass} style={bannerStyle}>
|
||||
{!profileUser.banner_url && (
|
||||
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZGVmcz48cGF0dGVybiBpZD0iZ3JpZCIgd2lkdGg9IjQwIiBoZWlnaHQ9IjQwIiBwYXR0ZXJuVW5pdHM9InVzZXJTcGFjZU9uVXNlIj48cGF0aCBkPSJNIDQwIDAgTCAwIDAgMCA0MCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSJ3aGl0ZSIgc3Ryb2tlLW9wYWNpdHk9IjAuMSIgc3Ryb2tlLXdpZHRoPSIxIi8+PC9wYXR0ZXJuPjwvZGVmcz48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSJ1cmwoI2dyaWQpIi8+PC9zdmc+')] opacity-30"></div>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-zinc-50 dark:from-black via-zinc-50/20 dark:via-black/20 to-transparent"></div>
|
||||
</div>
|
||||
|
||||
{/* Back Button */}
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="absolute top-4 left-4 flex items-center gap-2 text-white/80 hover:text-white bg-black/30 hover:bg-black/50 px-4 py-2 rounded-full backdrop-blur-sm transition-all z-20"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
|
||||
{/* Edit Banner Button (Owner Only) - Visual Cue */}
|
||||
{isOwner && (
|
||||
<button
|
||||
onClick={() => setIsEditModalOpen(true)}
|
||||
className="absolute top-4 right-4 bg-black/50 hover:bg-black/70 text-white p-2 rounded-full opacity-0 group-hover/banner:opacity-100 transition-opacity"
|
||||
title="Edit Banner" // Accessibility
|
||||
>
|
||||
<ImageIcon size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Profile Info */}
|
||||
<div className="max-w-7xl mx-auto px-4 md:px-8 -mt-16 md:-mt-20 relative z-10 w-full">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-end gap-4 md:gap-6">
|
||||
{/* Avatar */}
|
||||
<div className="group/avatar relative">
|
||||
<div className={`w-24 h-24 md:w-40 md:h-40 rounded-full border-4 border-zinc-50 dark:border-black bg-zinc-200 dark:bg-zinc-800 flex items-center justify-center overflow-hidden shadow-2xl ring-4 ${badgeRing} transition-transform ${paidPulse}`}>
|
||||
{profileUser.avatar_url && !avatarFailed ? (
|
||||
<img
|
||||
src={profileUser.avatar_url}
|
||||
alt={profileUser.username}
|
||||
className="w-full h-full object-cover"
|
||||
referrerPolicy="no-referrer"
|
||||
onError={() => setAvatarFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className={`w-full h-full bg-gradient-to-br ${bannerGradient} flex items-center justify-center text-4xl md:text-6xl font-bold text-white`}>
|
||||
{profileUser.username[0].toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isOwner && (
|
||||
<button
|
||||
onClick={() => setIsEditModalOpen(true)}
|
||||
className="absolute bottom-1 right-1 md:bottom-2 md:right-2 p-1.5 md:p-2 bg-zinc-200 dark:bg-zinc-800 rounded-full text-zinc-700 dark:text-white hover:bg-zinc-300 dark:hover:bg-zinc-700 border border-zinc-50 dark:border-black shadow-lg"
|
||||
>
|
||||
<Edit3 size={14} className="md:w-4 md:h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* User Info */}
|
||||
<div className="flex-1 pb-2 w-full">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className={`text-2xl md:text-5xl font-bold mb-1 ${paidNameStyle || 'text-zinc-900 dark:text-white'}`}>
|
||||
{profileUser.username}
|
||||
</h1>
|
||||
{profileUser.badges && profileUser.badges.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{profileUser.badges.map((badge) => {
|
||||
const style =
|
||||
badge.color === 'yellow'
|
||||
? 'from-yellow-300 via-amber-300 to-orange-400 text-amber-950 shadow-[0_0_20px_rgba(251,191,36,0.45)]'
|
||||
: badge.color === 'purple'
|
||||
? 'from-fuchsia-400 via-purple-500 to-indigo-500 text-white shadow-[0_0_20px_rgba(168,85,247,0.45)]'
|
||||
: badge.color === 'blue'
|
||||
? 'from-sky-400 via-blue-500 to-indigo-500 text-white shadow-[0_0_18px_rgba(59,130,246,0.45)]'
|
||||
: badge.color === 'teal'
|
||||
? 'from-teal-300 via-emerald-400 to-cyan-400 text-emerald-950 shadow-[0_0_18px_rgba(45,212,191,0.45)]'
|
||||
: badge.color === 'orange'
|
||||
? 'from-orange-300 via-amber-400 to-yellow-300 text-amber-950 shadow-[0_0_16px_rgba(251,146,60,0.4)]'
|
||||
: badge.color === 'pink'
|
||||
? 'from-pink-400 via-rose-500 to-fuchsia-500 text-white shadow-[0_0_18px_rgba(244,114,182,0.45)]'
|
||||
: badge.color === 'green'
|
||||
? 'from-emerald-300 via-green-400 to-lime-300 text-emerald-950 shadow-[0_0_16px_rgba(34,197,94,0.4)]'
|
||||
: 'from-zinc-200 via-zinc-300 to-zinc-200 text-zinc-700 dark:from-zinc-700 dark:via-zinc-600 dark:to-zinc-700 dark:text-zinc-100';
|
||||
|
||||
const icon =
|
||||
badge.id === 'supporter'
|
||||
? '🏅'
|
||||
: badge.id === 'patron'
|
||||
? '🏆'
|
||||
: badge.id === 'legendary'
|
||||
? '👑'
|
||||
: badge.id === 'diamond'
|
||||
? '💎'
|
||||
: badge.id === 'crown'
|
||||
? '👑'
|
||||
: badge.id === 'champion'
|
||||
? '🥇'
|
||||
: badge.id === 'music'
|
||||
? '🎵'
|
||||
: badge.id === 'coffee'
|
||||
? '☕'
|
||||
: '⭐';
|
||||
|
||||
return (
|
||||
<span
|
||||
key={badge.id}
|
||||
title={badge.description}
|
||||
className={`inline-flex items-center gap-2 px-3.5 py-1.5 rounded-full text-xs font-semibold border border-white/40 dark:border-white/10 bg-gradient-to-r ${style} transition-all duration-200 hover:-translate-y-0.5 hover:scale-[1.03] hover:brightness-110`}
|
||||
>
|
||||
<span className="text-sm drop-shadow">{icon}</span>
|
||||
{badge.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{profileUser.supporter_since && profileUser.accountTier && profileUser.accountTier !== 'free' && (
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400 mb-3">
|
||||
Supporting since {new Date(profileUser.supporter_since).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Bio */}
|
||||
{profileUser.bio && (
|
||||
<p className="text-zinc-700 dark:text-zinc-200 max-w-2xl mb-4 text-sm md:text-base leading-relaxed whitespace-pre-line">
|
||||
{profileUser.bio}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-zinc-500 text-xs md:text-sm mb-4">
|
||||
Joined {new Date(profileUser.created_at).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Edit Profile Button (Mobile/Desktop) */}
|
||||
{isOwner && (
|
||||
<button
|
||||
onClick={() => setIsEditModalOpen(true)}
|
||||
className="px-4 md:px-6 py-2 bg-zinc-900 dark:bg-white text-white dark:text-black hover:bg-zinc-800 dark:hover:bg-zinc-200 rounded-full font-bold transition-colors text-sm flex items-center gap-2"
|
||||
>
|
||||
<Edit3 size={16} />
|
||||
Edit Profile
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-4 md:gap-6 text-sm pt-2 border-t border-zinc-200 dark:border-white/10 mt-2 flex-wrap">
|
||||
<div className="flex items-center gap-1.5 md:gap-2">
|
||||
<MusicIcon size={16} className="text-zinc-500 dark:text-zinc-400" />
|
||||
<span className="font-semibold text-zinc-900 dark:text-white">{publicSongs.length}</span>
|
||||
<span className="text-zinc-500 dark:text-zinc-400">Songs</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 md:gap-2">
|
||||
<Heart size={16} className="text-zinc-500 dark:text-zinc-400" />
|
||||
<span className="font-semibold text-zinc-900 dark:text-white">{totalLikes}</span>
|
||||
<span className="text-zinc-500 dark:text-zinc-400">Likes</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 md:gap-2">
|
||||
<Eye size={16} className="text-zinc-500 dark:text-zinc-400" />
|
||||
<span className="font-semibold text-zinc-900 dark:text-white">{totalPlays}</span>
|
||||
<span className="text-zinc-500 dark:text-zinc-400">Plays</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="max-w-7xl mx-auto w-full px-4 md:px-8 py-6 md:py-8 space-y-8 md:space-y-12">
|
||||
{/* Featured Songs */}
|
||||
{featuredSongs.length > 0 && (
|
||||
<section>
|
||||
<h2 className="text-xl md:text-2xl font-bold text-zinc-900 dark:text-white mb-4 md:mb-6">Featured Songs</h2>
|
||||
<div className="flex gap-3 md:gap-4 overflow-x-auto pb-4 scrollbar-thin scrollbar-thumb-zinc-300 dark:scrollbar-thumb-zinc-700 scrollbar-track-transparent -mx-4 px-4 md:mx-0 md:px-0">
|
||||
{featuredSongs.map((song) => {
|
||||
const isCurrentSong = currentSong?.id === song.id;
|
||||
const isCurrentlyPlaying = isCurrentSong && isPlaying;
|
||||
const isLiked = likedSongIds.has(song.id);
|
||||
return (
|
||||
<div
|
||||
key={song.id}
|
||||
className="group relative flex-shrink-0 w-36 md:w-48"
|
||||
>
|
||||
<div
|
||||
onClick={() => onPlaySong(song, featuredSongs)}
|
||||
className="aspect-square rounded-lg overflow-hidden mb-2 md:mb-3 relative bg-zinc-200 dark:bg-zinc-800 cursor-pointer"
|
||||
>
|
||||
<img src={song.coverUrl} alt={song.title} className={`w-full h-full object-cover transition-transform duration-500 ${isCurrentlyPlaying ? 'scale-105' : 'group-hover:scale-105'}`} />
|
||||
<div className={`absolute inset-0 bg-black/40 transition-opacity flex items-center justify-center ${isCurrentSong ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}>
|
||||
<div className="w-12 h-12 md:w-14 md:h-14 rounded-full bg-white flex items-center justify-center shadow-lg">
|
||||
{isCurrentlyPlaying ? (
|
||||
<Pause size={20} className="text-black fill-black md:w-6 md:h-6" />
|
||||
) : (
|
||||
<Play size={20} className="text-black fill-black ml-1 md:w-6 md:h-6" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isCurrentlyPlaying && (
|
||||
<div className="absolute bottom-2 left-2 flex items-center gap-1">
|
||||
<span className="w-1 h-3 bg-pink-500 rounded-full animate-pulse" style={{ animationDelay: '0ms' }} />
|
||||
<span className="w-1 h-4 bg-pink-500 rounded-full animate-pulse" style={{ animationDelay: '150ms' }} />
|
||||
<span className="w-1 h-2 bg-pink-500 rounded-full animate-pulse" style={{ animationDelay: '300ms' }} />
|
||||
<span className="w-1 h-5 bg-pink-500 rounded-full animate-pulse" style={{ animationDelay: '450ms' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className={`font-semibold truncate mb-1 text-sm md:text-base ${isCurrentSong ? 'text-pink-500' : 'text-zinc-900 dark:text-white'}`}>{song.title}</h3>
|
||||
<p className="text-xs md:text-sm text-zinc-500 dark:text-zinc-400 truncate mb-2">{song.style}</p>
|
||||
</div>
|
||||
{onToggleLike && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onToggleLike(song.id); }}
|
||||
className={`p-1.5 rounded-full transition-colors ${isLiked ? 'text-pink-500' : 'text-zinc-400 hover:text-pink-500'}`}
|
||||
>
|
||||
<Heart size={16} className={isLiked ? 'fill-current' : ''} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-zinc-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<Heart size={12} className={isLiked ? 'fill-pink-500 text-pink-500' : ''} /> {song.likeCount || 0}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Eye size={12} /> {song.viewCount || 0}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Songs Section */}
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-4 md:mb-6">
|
||||
<h2 className="text-xl md:text-2xl font-bold text-zinc-900 dark:text-white">Songs</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex bg-zinc-200 dark:bg-zinc-900 rounded-full p-1">
|
||||
<button
|
||||
onClick={() => setSongsTab('recent')}
|
||||
className={`px-3 md:px-4 py-1.5 md:py-2 rounded-full text-xs md:text-sm font-medium transition-colors ${songsTab === 'recent' ? 'bg-white dark:bg-white text-zinc-900 dark:text-black shadow-sm' : 'text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Recent
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSongsTab('top')}
|
||||
className={`px-3 md:px-4 py-1.5 md:py-2 rounded-full text-xs md:text-sm font-medium transition-colors ${songsTab === 'top' ? 'bg-white dark:bg-white text-zinc-900 dark:text-black shadow-sm' : 'text-zinc-600 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Top
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{displaySongs.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-zinc-500">
|
||||
<MusicIcon size={64} className="mb-4 opacity-50" />
|
||||
<p>No public songs yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2 md:gap-4">
|
||||
{displaySongs.map((song) => {
|
||||
const isCurrentSong = currentSong?.id === song.id;
|
||||
const isCurrentlyPlaying = isCurrentSong && isPlaying;
|
||||
const isLiked = likedSongIds.has(song.id);
|
||||
return (
|
||||
<div
|
||||
key={song.id}
|
||||
className={`group flex items-center gap-3 md:gap-4 p-2 md:p-3 rounded-lg cursor-pointer transition-colors ${isCurrentSong ? 'bg-pink-50 dark:bg-pink-500/10' : 'hover:bg-zinc-100 dark:hover:bg-zinc-900'}`}
|
||||
>
|
||||
<div
|
||||
onClick={() => onPlaySong(song, displaySongs)}
|
||||
className="relative w-14 h-14 md:w-16 md:h-16 flex-shrink-0 rounded-md overflow-hidden bg-zinc-200 dark:bg-zinc-800"
|
||||
>
|
||||
<img src={song.coverUrl} alt={song.title} className="w-full h-full object-cover" />
|
||||
<div className={`absolute inset-0 bg-black/40 transition-opacity flex items-center justify-center ${isCurrentSong ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}>
|
||||
{isCurrentlyPlaying ? (
|
||||
<Pause size={18} className="text-white fill-white md:w-5 md:h-5" />
|
||||
) : (
|
||||
<Play size={18} className="text-white fill-white md:w-5 md:h-5" />
|
||||
)}
|
||||
</div>
|
||||
{isCurrentlyPlaying && (
|
||||
<div className="absolute bottom-1 left-1 flex items-center gap-0.5">
|
||||
<span className="w-0.5 h-2 bg-pink-500 rounded-full animate-pulse" style={{ animationDelay: '0ms' }} />
|
||||
<span className="w-0.5 h-3 bg-pink-500 rounded-full animate-pulse" style={{ animationDelay: '150ms' }} />
|
||||
<span className="w-0.5 h-1.5 bg-pink-500 rounded-full animate-pulse" style={{ animationDelay: '300ms' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0" onClick={() => onPlaySong(song, displaySongs)}>
|
||||
<h3 className={`font-semibold truncate text-sm md:text-base ${isCurrentSong ? 'text-pink-500' : 'text-zinc-900 dark:text-white'}`}>{song.title}</h3>
|
||||
<p className="text-xs md:text-sm text-zinc-500 dark:text-zinc-400 truncate">{song.style}</p>
|
||||
<div className="flex items-center gap-3 text-xs text-zinc-500 mt-1">
|
||||
<span className="flex items-center gap-1"><Heart size={10} className={isLiked ? 'fill-pink-500 text-pink-500' : ''} /> {song.likeCount || 0}</span>
|
||||
<span className="flex items-center gap-1"><Play size={10} /> {song.viewCount || 0}</span>
|
||||
<span>{song.duration}</span>
|
||||
</div>
|
||||
</div>
|
||||
{onToggleLike && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onToggleLike(song.id); }}
|
||||
className={`p-2 rounded-full transition-colors flex-shrink-0 ${isLiked ? 'text-pink-500' : 'text-zinc-400 hover:text-pink-500'}`}
|
||||
>
|
||||
<Heart size={18} className={isLiked ? 'fill-current' : ''} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Playlists Section */}
|
||||
{publicPlaylists.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-4 md:mb-6">
|
||||
<h2 className="text-xl md:text-2xl font-bold text-zinc-900 dark:text-white">Playlists</h2>
|
||||
<button className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors text-sm">
|
||||
See More <ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex gap-3 md:gap-4 overflow-x-auto pb-4 scrollbar-thin scrollbar-thumb-zinc-300 dark:scrollbar-thumb-zinc-700 scrollbar-track-transparent -mx-4 px-4 md:mx-0 md:px-0">
|
||||
{publicPlaylists.map((playlist: any) => (
|
||||
<div
|
||||
key={playlist.id}
|
||||
onClick={() => onNavigateToPlaylist?.(playlist.id)}
|
||||
className="group relative flex-shrink-0 w-36 md:w-48 cursor-pointer"
|
||||
>
|
||||
<div className="aspect-square rounded-lg bg-gradient-to-br from-indigo-600 to-purple-700 mb-2 md:mb-3 flex items-center justify-center relative overflow-hidden">
|
||||
<MusicIcon size={48} className="text-white/30 md:w-16 md:h-16" />
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<div className="w-12 h-12 md:w-14 md:h-14 rounded-full bg-white flex items-center justify-center">
|
||||
<Play size={20} className="text-black fill-black ml-1 md:w-6 md:h-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="font-semibold text-zinc-900 dark:text-white truncate mb-1 text-sm md:text-base">{playlist.name}</h3>
|
||||
<p className="text-xs md:text-sm text-zinc-500 dark:text-zinc-400">{playlist.song_count} songs</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Edit Profile Modal */}
|
||||
{isEditModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 dark:bg-black/80 backdrop-blur-sm p-4">
|
||||
<div className="w-full max-w-lg bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-2xl shadow-2xl overflow-hidden animate-in fade-in zoom-in-95 duration-200 max-h-[90vh] overflow-y-auto">
|
||||
<div className="px-4 md:px-6 py-4 border-b border-zinc-200 dark:border-zinc-800 flex items-center justify-between sticky top-0 bg-white dark:bg-zinc-900 z-10">
|
||||
<h2 className="text-lg md:text-xl font-bold text-zinc-900 dark:text-white">Edit Profile</h2>
|
||||
<button onClick={() => setIsEditModalOpen(false)} className="text-zinc-500 dark:text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 md:p-6 space-y-6">
|
||||
{/* Avatar Upload */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Avatar Image</label>
|
||||
<div className="flex gap-4 items-center">
|
||||
<div className="w-20 h-20 rounded-full bg-zinc-100 dark:bg-zinc-800 border-2 border-zinc-300 dark:border-zinc-700 border-dashed overflow-hidden flex-shrink-0 relative">
|
||||
{(avatarPreview || editAvatarUrl) ? (
|
||||
<img
|
||||
src={avatarPreview || editAvatarUrl}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => (e.currentTarget.style.display = 'none')}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-zinc-400 dark:text-zinc-500">
|
||||
<Camera size={24} />
|
||||
</div>
|
||||
)}
|
||||
{uploadingAvatar && (
|
||||
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
|
||||
<Loader2 size={20} className="animate-spin text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<input
|
||||
ref={avatarInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
onChange={handleAvatarChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700 text-zinc-900 dark:text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
<Upload size={16} />
|
||||
Upload Avatar
|
||||
</button>
|
||||
<p className="text-xs text-zinc-500">JPG, PNG, WebP, GIF • Max 5MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Banner Upload */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Banner Image</label>
|
||||
<div
|
||||
onClick={() => bannerInputRef.current?.click()}
|
||||
className="relative w-full h-32 rounded-lg bg-zinc-100 dark:bg-zinc-800 border-2 border-zinc-300 dark:border-zinc-700 border-dashed overflow-hidden cursor-pointer hover:border-zinc-400 dark:hover:border-zinc-600 transition-colors"
|
||||
>
|
||||
{(bannerPreview || editBannerUrl) ? (
|
||||
<img
|
||||
src={bannerPreview || editBannerUrl}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => (e.currentTarget.style.display = 'none')}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center text-zinc-400 dark:text-zinc-500 gap-2">
|
||||
<ImageIcon size={32} />
|
||||
<span className="text-sm">Click to upload banner</span>
|
||||
</div>
|
||||
)}
|
||||
{uploadingBanner && (
|
||||
<div className="absolute inset-0 bg-black/60 flex items-center justify-center">
|
||||
<Loader2 size={24} className="animate-spin text-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={bannerInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
onChange={handleBannerChange}
|
||||
className="hidden"
|
||||
/>
|
||||
<p className="text-xs text-zinc-500">Recommended: 1500x500px • JPG, PNG, WebP, GIF • Max 5MB</p>
|
||||
</div>
|
||||
|
||||
{/* Bio Input */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">Bio</label>
|
||||
<textarea
|
||||
value={editBio}
|
||||
onChange={(e) => setEditBio(e.target.value)}
|
||||
placeholder="Tell us about yourself..."
|
||||
rows={4}
|
||||
className="w-full bg-zinc-50 dark:bg-black border border-zinc-300 dark:border-zinc-800 rounded-lg px-3 py-2 text-zinc-900 dark:text-white placeholder-zinc-400 dark:placeholder-zinc-600 focus:outline-none focus:border-pink-500 dark:focus:border-indigo-500 transition-colors resize-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 md:px-6 py-4 bg-zinc-50 dark:bg-black/20 border-t border-zinc-200 dark:border-zinc-800 flex justify-end gap-3 sticky bottom-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsEditModalOpen(false);
|
||||
setAvatarFile(null);
|
||||
setBannerFile(null);
|
||||
setAvatarPreview(null);
|
||||
setBannerPreview(null);
|
||||
}}
|
||||
className="px-4 py-2 text-sm font-medium text-zinc-600 dark:text-zinc-300 hover:text-zinc-900 dark:hover:text-white transition-colors"
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveProfile}
|
||||
disabled={isSaving || uploadingAvatar || uploadingBanner}
|
||||
className="px-6 py-2 bg-zinc-900 dark:bg-white text-white dark:text-black hover:bg-zinc-800 dark:hover:bg-zinc-200 rounded-full text-sm font-bold transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{isSaving && <Loader2 size={16} className="animate-spin" />}
|
||||
{uploadingAvatar ? 'Uploading Avatar...' : uploadingBanner ? 'Uploading Banner...' : isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, User, Sparkles } from 'lucide-react';
|
||||
|
||||
interface UsernameModalProps {
|
||||
isOpen: boolean;
|
||||
onSubmit: (username: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const UsernameModal: React.FC<UsernameModalProps> = ({ isOpen, onSubmit }) => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
const trimmed = username.trim();
|
||||
if (trimmed.length < 2) {
|
||||
setError('Username must be at least 2 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
|
||||
setError('Username can only contain letters, numbers, underscores, and dashes');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await onSubmit(trimmed);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to set username');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/80 backdrop-blur-sm" />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-md bg-zinc-900 rounded-2xl shadow-2xl border border-white/10 overflow-hidden">
|
||||
{/* Header gradient */}
|
||||
<div className="h-2 bg-gradient-to-r from-pink-500 via-purple-500 to-blue-500" />
|
||||
|
||||
<div className="p-8">
|
||||
{/* Logo */}
|
||||
<div className="flex justify-center mb-6">
|
||||
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-pink-500 to-purple-600 flex items-center justify-center shadow-lg">
|
||||
<Sparkles className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h2 className="text-2xl font-bold text-center text-white mb-2">
|
||||
Welcome to ACE-Step UI
|
||||
</h2>
|
||||
<p className="text-zinc-400 text-center mb-8">
|
||||
Enter your name to get started creating AI music
|
||||
</p>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-zinc-300 mb-2">
|
||||
Your Name
|
||||
</label>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<User className="w-5 h-5 text-zinc-500" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter your name"
|
||||
className="w-full pl-10 pr-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-pink-500 focus:border-transparent transition-all"
|
||||
autoFocus
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="mt-2 text-sm text-red-400">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !username.trim()}
|
||||
className="w-full py-3 bg-gradient-to-r from-pink-500 to-purple-600 text-white font-semibold rounded-xl hover:from-pink-600 hover:to-purple-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all transform hover:scale-[1.02] active:scale-[0.98]"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Getting Started...
|
||||
</span>
|
||||
) : (
|
||||
'Get Started'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="mt-6 text-xs text-zinc-500 text-center">
|
||||
Your music, your way. Create unlimited AI music for free.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react';
|
||||
import { authApi, User } from '../services/api';
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
setupUser: (username: string) => Promise<void>;
|
||||
updateUsername: (username: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
refreshUser: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
const TOKEN_KEY = 'acestep_token';
|
||||
const USER_KEY = 'acestep_user';
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }): React.ReactElement {
|
||||
// Start with null - we'll auto-login from database on mount
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const isAuthenticated = !!user && !!token;
|
||||
|
||||
// Auto-login on mount: Try to get existing user from database
|
||||
useEffect(() => {
|
||||
async function initAuth(): Promise<void> {
|
||||
try {
|
||||
// First, try auto-login from database (for local single-user app)
|
||||
const { user: userData, token: newToken } = await authApi.auto();
|
||||
setUser(userData);
|
||||
setToken(newToken);
|
||||
localStorage.setItem(TOKEN_KEY, newToken);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(userData));
|
||||
} catch (error: unknown) {
|
||||
// No user in database (404) or server error - that's okay
|
||||
// Clear any stale localStorage data
|
||||
const err = error as { message?: string };
|
||||
if (err.message?.startsWith('404:')) {
|
||||
// No user exists yet - frontend will show username setup
|
||||
console.log('No user in database, need to set up username');
|
||||
} else {
|
||||
console.warn('Auto-login failed:', error);
|
||||
}
|
||||
// Clear stale data
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
initAuth();
|
||||
}, []);
|
||||
|
||||
const setupUser = useCallback(async (username: string): Promise<void> => {
|
||||
const { user: userData, token: newToken } = await authApi.setup(username);
|
||||
setUser(userData);
|
||||
setToken(newToken);
|
||||
localStorage.setItem(TOKEN_KEY, newToken);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(userData));
|
||||
}, []);
|
||||
|
||||
const updateUsername = useCallback(async (username: string): Promise<void> => {
|
||||
if (!token) throw new Error('Not authenticated');
|
||||
const { user: userData, token: newToken } = await authApi.updateUsername(username, token);
|
||||
setUser(userData);
|
||||
setToken(newToken);
|
||||
localStorage.setItem(TOKEN_KEY, newToken);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(userData));
|
||||
}, [token]);
|
||||
|
||||
const logout = useCallback((): void => {
|
||||
authApi.logout().catch(() => {});
|
||||
setUser(null);
|
||||
setToken(null);
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
}, []);
|
||||
|
||||
const refreshUser = useCallback(async (): Promise<void> => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const { user: userData } = await authApi.me(token);
|
||||
setUser(userData);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(userData));
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh user:', error);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
const value: AuthContextType = {
|
||||
user,
|
||||
token,
|
||||
isLoading,
|
||||
isAuthenticated,
|
||||
setupUser,
|
||||
updateUsername,
|
||||
logout,
|
||||
refreshUser,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextType {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode, useMemo } from 'react';
|
||||
|
||||
interface ResponsiveContextType {
|
||||
isMobile: boolean;
|
||||
isDesktop: boolean;
|
||||
}
|
||||
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
const ResponsiveContext = createContext<ResponsiveContextType | undefined>(undefined);
|
||||
|
||||
function getInitialState(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
return window.innerWidth < MOBILE_BREAKPOINT;
|
||||
}
|
||||
|
||||
export function ResponsiveProvider({ children }: { children: ReactNode }): React.ReactElement {
|
||||
const [isMobile, setIsMobile] = useState<boolean>(getInitialState);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaQuery = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
||||
|
||||
const handleChange = (event: MediaQueryListEvent) => {
|
||||
setIsMobile(event.matches);
|
||||
};
|
||||
|
||||
setIsMobile(mediaQuery.matches);
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', handleChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value = useMemo<ResponsiveContextType>(() => ({
|
||||
isMobile,
|
||||
isDesktop: !isMobile,
|
||||
}), [isMobile]);
|
||||
|
||||
return <ResponsiveContext.Provider value={value}>{children}</ResponsiveContext.Provider>;
|
||||
}
|
||||
|
||||
export function useResponsive(): ResponsiveContextType {
|
||||
const context = useContext(ResponsiveContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useResponsive must be used within a ResponsiveProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
After Width: | Height: | Size: 14 MiB |
@@ -0,0 +1,36 @@
|
||||
interface GoogleCredentialResponse {
|
||||
credential: string;
|
||||
select_by?: string;
|
||||
}
|
||||
|
||||
interface GoogleAccountsId {
|
||||
initialize: (options: {
|
||||
client_id: string;
|
||||
callback: (response: GoogleCredentialResponse) => void;
|
||||
ux_mode?: 'popup' | 'redirect';
|
||||
}) => void;
|
||||
renderButton: (
|
||||
parent: HTMLElement,
|
||||
options?: {
|
||||
theme?: 'outline' | 'filled_blue' | 'filled_black';
|
||||
size?: 'large' | 'medium' | 'small';
|
||||
type?: 'standard' | 'icon';
|
||||
shape?: 'rectangular' | 'pill' | 'circle' | 'square';
|
||||
text?: 'signin_with' | 'signup_with' | 'continue_with' | 'signin';
|
||||
width?: number;
|
||||
locale?: string;
|
||||
}
|
||||
) => void;
|
||||
}
|
||||
|
||||
interface GoogleAccounts {
|
||||
id: GoogleAccountsId;
|
||||
}
|
||||
|
||||
interface GoogleGlobal {
|
||||
accounts: GoogleAccounts;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
google?: GoogleGlobal;
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ACE-Step UI - Local AI Music Generator</title>
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<meta name="title" content="ACE-Step UI - Local AI Music Generator" />
|
||||
<meta name="description" content="Create AI music locally with ACE-Step UI. Free and open source music generation powered by ACE-Step 1.5." />
|
||||
<meta name="keywords" content="AI music generator, ACE-Step, local AI, music generation, text to music, AI song generator" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
<meta name="author" content="ACE-Step UI" />
|
||||
|
||||
<!-- Additional SEO -->
|
||||
<meta name="theme-color" content="#ec4899" />
|
||||
<meta name="application-name" content="ACE-Step UI" />
|
||||
<meta name="apple-mobile-web-app-title" content="ACE-Step UI" />
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
suno: {
|
||||
DEFAULT: '#09090b',
|
||||
sidebar: '#000000',
|
||||
panel: '#121214',
|
||||
card: '#18181b',
|
||||
hover: '#27272a',
|
||||
border: '#27272a',
|
||||
}
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'sans-serif'],
|
||||
},
|
||||
animation: {
|
||||
'gradient-x': 'gradient-x 15s ease infinite',
|
||||
},
|
||||
keyframes: {
|
||||
'gradient-x': {
|
||||
'0%, 100%': {
|
||||
'background-size': '200% 200%',
|
||||
'background-position': 'left center'
|
||||
},
|
||||
'50%': {
|
||||
'background-size': '200% 200%',
|
||||
'background-position': 'right center'
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
|
||||
:root {
|
||||
--breakpoint-mobile: 768px;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #d4d4d8;
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a1a1aa;
|
||||
}
|
||||
|
||||
/* Dark mode scrollbar */
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background: #3f3f46;
|
||||
}
|
||||
.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background: #52525b;
|
||||
}
|
||||
|
||||
/* Hide scrollbar utility */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: linear-gradient(to right, #f472b6, #a78bfa, #60a5fa);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
@keyframes music-bars {
|
||||
0% { height: 20%; opacity: 0.5; }
|
||||
50% { height: 100%; opacity: 1; }
|
||||
100% { height: 20%; opacity: 0.5; }
|
||||
}
|
||||
|
||||
.music-bar-anim {
|
||||
animation: music-bars 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.mobile-only {
|
||||
display: none;
|
||||
}
|
||||
.desktop-only {
|
||||
display: block;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.mobile-only {
|
||||
display: block;
|
||||
}
|
||||
.desktop-only {
|
||||
display: none;
|
||||
}
|
||||
.mobile-only-flex {
|
||||
display: flex;
|
||||
}
|
||||
.mobile-only-inline {
|
||||
display: inline;
|
||||
}
|
||||
.mobile-only-inline-flex {
|
||||
display: inline-flex;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-in-left {
|
||||
from { transform: translateX(-100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
@keyframes slide-out-left {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(-100%); }
|
||||
}
|
||||
@keyframes slide-in-right {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
@keyframes slide-out-right {
|
||||
from { transform: translateX(0); }
|
||||
to { transform: translateX(100%); }
|
||||
}
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes fade-out {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
.drawer-slide-in-left {
|
||||
animation: slide-in-left 0.3s ease-out forwards;
|
||||
}
|
||||
.drawer-slide-out-left {
|
||||
animation: slide-out-left 0.3s ease-in forwards;
|
||||
}
|
||||
.drawer-slide-in-right {
|
||||
animation: slide-in-right 0.3s ease-out forwards;
|
||||
}
|
||||
.drawer-slide-out-right {
|
||||
animation: slide-out-right 0.3s ease-in forwards;
|
||||
}
|
||||
.drawer-backdrop-in {
|
||||
animation: fade-in 0.3s ease-out forwards;
|
||||
}
|
||||
.drawer-backdrop-out {
|
||||
animation: fade-out 0.3s ease-in forwards;
|
||||
}
|
||||
|
||||
.backdrop-blur-mobile {
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
}
|
||||
.backdrop-blur-mobile-lg {
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.safe-area-inset-top {
|
||||
padding-top: env(safe-area-inset-top, 0px);
|
||||
}
|
||||
.safe-area-inset-bottom {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
.safe-area-inset-left {
|
||||
padding-left: env(safe-area-inset-left, 0px);
|
||||
}
|
||||
.safe-area-inset-right {
|
||||
padding-right: env(safe-area-inset-right, 0px);
|
||||
}
|
||||
.safe-area-inset-x {
|
||||
padding-left: env(safe-area-inset-left, 0px);
|
||||
padding-right: env(safe-area-inset-right, 0px);
|
||||
}
|
||||
.safe-area-inset-y {
|
||||
padding-top: env(safe-area-inset-top, 0px);
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
.safe-area-inset-all {
|
||||
padding-top: env(safe-area-inset-top, 0px);
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
padding-left: env(safe-area-inset-left, 0px);
|
||||
padding-right: env(safe-area-inset-right, 0px);
|
||||
}
|
||||
|
||||
.touch-none {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
touch-action: none;
|
||||
}
|
||||
.touch-pan-x {
|
||||
touch-action: pan-x;
|
||||
}
|
||||
.touch-pan-y {
|
||||
touch-action: pan-y;
|
||||
}
|
||||
.touch-manipulation {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
.tap-highlight-none {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.scroll-touch {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
.overscroll-none {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
.overscroll-contain {
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
</style>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"react-dom/": "https://esm.sh/react-dom@^19.2.4/",
|
||||
"lucide-react": "https://esm.sh/lucide-react@^0.563.0",
|
||||
"@google/genai": "https://esm.sh/@google/genai@^1.38.0",
|
||||
"react/": "https://esm.sh/react@^19.2.4/",
|
||||
"react": "https://esm.sh/react@^19.2.4"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link rel="stylesheet" href="/index.css">
|
||||
</head>
|
||||
<body class="bg-suno text-zinc-900 dark:text-white overflow-hidden transition-colors duration-300">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import { ResponsiveProvider } from './context/ResponsiveContext';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error("Could not find root element to mount to");
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<AuthProvider>
|
||||
<ResponsiveProvider>
|
||||
<App />
|
||||
</ResponsiveProvider>
|
||||
</AuthProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "Suno Clone UI",
|
||||
"description": "A high-fidelity recreation of the Suno AI music generation interface using React, Tailwind CSS, and Gemini for lyric generation.",
|
||||
"requestFramePermissions": []
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "ace-step-ui",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"start": "cd server && npx tsx src/index.ts & npm run dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ffmpeg/ffmpeg": "^0.12.15",
|
||||
"@ffmpeg/util": "^0.12.2",
|
||||
"@google/genai": "^1.38.0",
|
||||
"lucide-react": "^0.563.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
# ACE-Step UI Configuration
|
||||
|
||||
# Server
|
||||
PORT=3001
|
||||
NODE_ENV=development
|
||||
|
||||
# Database (SQLite)
|
||||
DATABASE_PATH=./data/acestep.db
|
||||
|
||||
# ACE-Step API (local)
|
||||
ACESTEP_API_URL=http://localhost:8001
|
||||
|
||||
# Storage
|
||||
AUDIO_DIR=./public/audio
|
||||
|
||||
# Frontend
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
VITE_API_URL=http://localhost:3001
|
||||
|
||||
# JWT Secret (for local session management)
|
||||
JWT_SECRET=ace-step-ui-local-secret
|
||||
|
||||
# Optional: Pexels API Key (for video backgrounds)
|
||||
# Get a free API key at https://www.pexels.com/api/
|
||||
PEXELS_API_KEY=
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
@@ -0,0 +1,321 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>AudioMass - About</title>
|
||||
<meta charset="utf-8"/>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
|
||||
<meta name="description" content="AudioMass is a free full-featured web-based audio & waveform editing tool"/>
|
||||
<meta property="og:image" content="https://audiomass.co/icon.jpg"/>
|
||||
<meta property="og:title" content="AudioMass">
|
||||
<meta property="og:url" content="https://audiomass.co/">
|
||||
<meta property="og:description" content="AudioMass is a free full-featured web-based audio & waveform editing tool">
|
||||
<meta name="keywords" content="AudioMass, WebAudio, WaveForm, audio editing, free audio editing, audio tool, waveform editor, sound editor, open source">
|
||||
<link href="/ico.ico" rel="shortcut icon">
|
||||
|
||||
<style>
|
||||
body{
|
||||
padding:0;margin:0;
|
||||
font-family: "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif;
|
||||
color:rgb(5, 10, 5);
|
||||
background:#fefefe
|
||||
}
|
||||
#w{
|
||||
width: 90%;
|
||||
max-width:960px;
|
||||
margin:0 auto
|
||||
}
|
||||
|
||||
#w > div{
|
||||
padding:20px 0 0 0
|
||||
}
|
||||
|
||||
p{
|
||||
line-height:21px;
|
||||
font-size:16px;
|
||||
margin-bottom:1.2em
|
||||
}
|
||||
|
||||
h1{
|
||||
font-size: 26px;
|
||||
margin-top:35px
|
||||
}
|
||||
|
||||
h5{
|
||||
font-size:1em;
|
||||
margin-bottom:0
|
||||
}
|
||||
|
||||
.top{
|
||||
max-width:100%;
|
||||
outline:0;
|
||||
display:inline-block
|
||||
}
|
||||
|
||||
.mid{
|
||||
max-width:504px;
|
||||
width: 90%;
|
||||
outline:0;
|
||||
margin-bottom:16px;
|
||||
display:inline-block
|
||||
}
|
||||
|
||||
.nomarg{
|
||||
margin-bottom:0
|
||||
}
|
||||
|
||||
pre {
|
||||
padding:12px 16px;
|
||||
display:inline-block;
|
||||
border-radius:6px;
|
||||
background:#e1f3e9;
|
||||
text-shadow:0 1px #fff;
|
||||
border-bottom:1px solid #aaa;
|
||||
max-width:90%;
|
||||
overflow:scroll
|
||||
}
|
||||
|
||||
ul {
|
||||
padding:0;
|
||||
list-style:none
|
||||
}
|
||||
|
||||
li {
|
||||
padding:0 0 3px 0;
|
||||
color:#333;
|
||||
font-size:0.92em
|
||||
}
|
||||
|
||||
video {
|
||||
max-width:660px;
|
||||
width:90%;
|
||||
}
|
||||
|
||||
::-moz-selection { background: #080808; color:#fff; text-shadow:none }
|
||||
::selection { background: #080808; color:#fff; text-shadow:none }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="w">
|
||||
|
||||
|
||||
<div>
|
||||
<h1>Introducing AudioMass (<a href="https://audiomass.co" target="_blank">https://audiomass.co</a>) an open-source web based audio and waveform editing tool.</h1>
|
||||
|
||||
|
||||
<img class="top" src="/about/audiomass_top.jpg" />
|
||||
|
||||
|
||||
<p>AudioMass lets you record, or use your existing audio tracks, and modify them by trimming, cutting, pasting or applying a plethora of effects, from compression and paragraphic equalizers to reverb, delay and distortion fx. AudioMass also supports more than 20 hotkeys combinations and a dynamic responsive interface to ensure ease of use and that your producivity remains high.
|
||||
it is written solely in plain old-school javascript, weights approximately 65kb and has no backend or framework dependencies.</p>
|
||||
|
||||
<img class="mid" src="/about/audiomass_support.jpg" />
|
||||
<p>It also has very good browser and device support.</p>
|
||||
|
||||
|
||||
<h5>:: Feature List ::</h5>
|
||||
|
||||
<ul>
|
||||
<li>Loading Audio, navigating the waveform, zoom and pan</li>
|
||||
<li>Visualization of frequency levels</li>
|
||||
<li>Peak and distortion signaling</li>
|
||||
<li>Cutting/Pasting/Trimming parts of the audio</li>
|
||||
<li>Inverting and Reversing Audio</li>
|
||||
<li>Exporting to mp3</li>
|
||||
<li>Modifying volume levels</li>
|
||||
<li>Fade In/Out</li>
|
||||
<li>Compressor</li>
|
||||
<li>Normalization</li>
|
||||
<li>Reverb</li>
|
||||
<li>Delay</li>
|
||||
<li>Distortion</li>
|
||||
<li>Pitch Shift</li>
|
||||
<li>Keeps track of states so you can undo mistakes</li>
|
||||
<li>Offline support!</li>
|
||||
</ul>
|
||||
|
||||
<p>And all this, in only 83kb of JS!</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<h3>Getting Started</h3>
|
||||
|
||||
|
||||
<p>To get started, drag and drop an audio file, or try the included sample.<br />
|
||||
Once the file is loaded and you can view the waveform, zoom in, pan around, or select a region.</p>
|
||||
|
||||
<img class="mid" src="/about/audiomass_2.jpg" />
|
||||
|
||||
|
||||
<h3>Recording Audio</h3>
|
||||
|
||||
<p>To record audio, simply press the Recording button, or the <i>[R]</i> key.</p>
|
||||
|
||||
<img class="mid" src="/about/audiomass_3.jpg" />
|
||||
|
||||
|
||||
<h3>Exporting to mp3</h3>
|
||||
|
||||
<p>In order to export back to mp3, click on 'File', then 'Export to mp3', and follow the modal's instructions.</p>
|
||||
|
||||
<img class="mid" src="/about/audiomass_4.jpg" />
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<hr />
|
||||
|
||||
|
||||
<div>
|
||||
<h3>The story behind AudioMass. And a short rant on web interfaces.</h3>
|
||||
|
||||
<p>I wrote AudioMass back in June 2018 and it stayed dormant on my hard disk until I decided to share it with the world today (July 13th, 19 -- but you might be seeing this in 2020. Hi!!).
|
||||
It started as a personal small tool for quick visualization of waveforms. Later I added the ability to cut/copy/paste as well as fade in and out. And soon after good 'ol feature creep and perfectionism took over! Soon after, it turned to a challenge to see how close to a full-featured waveform editor it can get, whilst maintaining acceptable performance and small filesize.</p>
|
||||
|
||||
<p>In general I am a big fan of the interfaces of DAWs <i>(Digital Audio Workstations)</i>, they are extremely, complex, intricate, versatile, and they manage to remain visually pleasant even through their infinite options and knobs. Many times I feel the web has taken a very wrong turn, as amazing interfaces such as...
|
||||
|
||||
<p class="nomarg">Sonar</p>
|
||||
<img class="mid" src="/about/sonar.jpg" />
|
||||
|
||||
<br />
|
||||
<p class="nomarg">Fruity loops</p>
|
||||
<img class="mid" src="/about/fruity.png" />
|
||||
|
||||
<br />
|
||||
|
||||
<p>Existed for more than 10-15 years now, while we are struggling with animating some rectangles for 60fps... So for AudioMass I wanted to try and make a fast and performant interface. Drawing inspiration from the examples I mentioned earlier rather than the tradiional web development practices. This is my unconvincing but truthfull excuse as to why the code is ugly; it is focused on being fast and getting the job done, with little regard on structure.</p>
|
||||
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div>
|
||||
|
||||
<h3>Building the interface</h3>
|
||||
|
||||
<p>Let's say we have a <i>"PLAY"</i> button and when we press it the track begins to play. I suppose we would want the button's color and state to reflect that the track is now playing. So naively we would do something like;</p>
|
||||
|
||||
|
||||
<pre><code>btn.onclick = function () {
|
||||
this.classList.add ('active');
|
||||
};</code></pre>
|
||||
|
||||
<p>But what happens if we have a hotkey that triggers the same action? Let's say we press <i>[SPACEBAR]</i> and the track begins to play. Do we modify that button's class in the spacebar's handler?</p>
|
||||
|
||||
<pre><code>document.onkeypress = function ( e ) {
|
||||
if ( e.keyCode === 32) {
|
||||
e.preventDefault ();
|
||||
|
||||
document.querySelector ('.playbtn').classList.add ('active');
|
||||
}
|
||||
};</code></pre>
|
||||
|
||||
<p>And what happens, if there are 2 buttons, or one gets dynamically removed? Do we do selectAll and iterate? Hmmm...<br />
|
||||
And if the track is playing and we hit <i>[SPACEBAR]</i> or the play button again, we need to stop playing. What do we do then? You can see how this becomes messy very quickly as everything gets very tighlty coupled together in a big dependency ball.</p>
|
||||
|
||||
|
||||
|
||||
<p>Introducing the observer pattern. Actions are represented by events. So the above logic and be expressed as;</p>
|
||||
|
||||
|
||||
<pre><code>btn.onclick = function () {
|
||||
FireEvent ('RequestTogglePlay');
|
||||
};
|
||||
|
||||
On ('WillPlay', function () {
|
||||
btn.classList.add ('active');
|
||||
});
|
||||
|
||||
On ('WillStop', function () {
|
||||
btn.classList.remove ('active');
|
||||
});
|
||||
|
||||
|
||||
|
||||
document.onkeypress = function ( e ) {
|
||||
if ( e.keyCode === 32) {
|
||||
e.preventDefault ();
|
||||
|
||||
FireEvent ('RequestTogglePlay');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
On ('RequestTogglePlay', function () {
|
||||
if (track.is_playing) {
|
||||
FireEvent ('WillStop');
|
||||
track.stop ();
|
||||
}
|
||||
else {
|
||||
FireEvent ('WillPlay');
|
||||
track.play ();
|
||||
}
|
||||
});
|
||||
|
||||
track.onPlayStart = function () {
|
||||
FireEvent ('DidPlay');
|
||||
};
|
||||
track.onPlayStop = function () {
|
||||
FireEvent ('DidStop');
|
||||
};
|
||||
</code></pre>
|
||||
|
||||
<p>Now this is completely decoupled and dependency free! The button will set its state according to the events it receives, and both the button and the spacebar key rely on the same mechanisms.
|
||||
You may notice the vocabulary we are using. <i>"Request"</i>, <i>"Will"</i> and <i>"Did"</i>. These are arbitrarilly chosen to impose some extra structure.<br /> <i>"Request"</i> denotes intent to perform an action, it is not guaranteed that the action will execute as there might be conditions preventing it (eg unitialized or still loading objects). <i>"Will"</i> means that the conditions passed and we are attempting to perform the action. And <i>"Did"</i> means that the action just got performed.<br />
|
||||
It might be a bit too verbose, but it worked very well for structuring AudioMass's interface.</p>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div>
|
||||
<h3>Dockable UI</h3>
|
||||
|
||||
<p>One thing I love about DAW interfaces, is that every window can be pulled out of the main host. I fondly remember having 3 screens full of VST plugins. So can we do the same in the browser?</p>
|
||||
|
||||
|
||||
<video src="/about/dock_ui.mp4" autoplay muted playsinline controls loop></video>
|
||||
|
||||
|
||||
<p>Yes! And it is using some of the oldest tricks in the book. Essentially we create a pop-up window with <i>window.open</i> and just pass buffers to its <i>documentWindow</i> object. Surprisingly it is quite performant on all browsers except IE Edge. I believe they are serializing in ascii or base64 every packet or something. Also Chrome has an interesting bug where you can't pass more than 512 byte buffers.</p>
|
||||
|
||||
<p>So for the undocked window, we call <i>window.open</i>. But how do we make it work when it is docked? It would be quite cumbersome to write the same functionality twice, once as a standalone page, and once as an in-page script. Luckily we can avoid that completely, and re-use the same page by using iframes.</p>
|
||||
|
||||
<p>The only difference is that the undocked version uses <i>window.opener</i> to refference its parent, whereas the iframe uses <i>window.parent</i>.</p>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<hr />
|
||||
|
||||
<div>
|
||||
<h3>Future work and performance considerations</h3>
|
||||
|
||||
<p>The next big feature that is planned, is multitrack support. The ability to mix tracks and different sounds is currently lacking and its usefulness as it stands is limited.</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<p>There is also a lot of room for improvement in almost all aspects.</p>
|
||||
|
||||
<p>First of all we can further reduce the filesize by around 20kb by removing the library we are using for rendering the waveform. We use only a fraction of its functionality so there is no reason to include it all.</p>
|
||||
|
||||
<p>We can also optimize a lot the rendering of the waveform. I heavily modified the library used to compute and draw only the visible range. However it is still clearing and re-drawing all of the canvas at each frame. We can take advantage of 2d Context's translate calls, and shift the canvas around instead of redrawing all of the pixels.</p>
|
||||
|
||||
<p>We can also move some operations to a background thread, such as the filters processing so that the UI does not freeze when applying a long chain of effects.</p>
|
||||
|
||||
<br />
|
||||
|
||||
|
||||
<p>However, the biggest issue I encountered, is the Web Audio API itself. Every operation results in iterating over multiple long arrays per frame. Eventually the garbage collector fires and crackling is introduced. Only way to go around this is to use a small fftSize, but then the frequency range we have to work with is very narrow. Perhaps a pure WASM implementation would outperform trying to modify audio signals with JS. Only one way to find out I guess :) </p>
|
||||
<p>Additionally, <i>decodeAudioData</i> provides no progress callback, and no way of cancelling it. So if you attempt to load a huge audio file, you will waste resources until it gets processed. There is no way around this currently and it can get annoying if you push a big file by mistake.</p>
|
||||
|
||||
</div>
|
||||
|
||||
<br /><br /><br />
|
||||
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 757 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 135 KiB |