Initial commit: ACE-Step UI - Open source music generation interface

This commit is contained in:
fspecii
2026-02-04 03:09:38 +02:00
commit 44f7563014
187 changed files with 99985 additions and 0 deletions
+25
View File
@@ -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=
+92
View File
@@ -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/
+1095
View File
File diff suppressed because it is too large Load Diff
+197
View File
@@ -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.
![ACE-Step UI](https://img.shields.io/badge/ACE--Step-1.5-pink)
![License](https://img.shields.io/badge/license-MIT-blue)
## 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
+24
View File
@@ -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!
+321
View File
@@ -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 &amp; 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 &amp; 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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 757 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

+1788
View File
File diff suppressed because it is too large Load Diff
+107
View File
@@ -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);
+49
View File
@@ -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))
}
+7
View File
@@ -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);
}
+38
View File
@@ -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
+205
View File
@@ -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 );
+178
View File
@@ -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 );
+2904
View File
File diff suppressed because it is too large Load Diff
+220
View File
@@ -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>
+131
View File
@@ -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;
}
Binary file not shown.
+26
View File
@@ -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="&#x20;" horiz-adv-x="512" d="" />
<glyph unicode="&#xe925;" 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="&#xe926;" 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="&#xe987;" 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="&#xe988;" 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="&#xe996;" 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="&#xea1c;" glyph-name="play3" d="M192 832l640-384-640-384z" />
<glyph unicode="&#xea1d;" glyph-name="pause2" d="M128 832h320v-768h-320zM576 832h320v-768h-320z" />
<glyph unicode="&#xea1e;" glyph-name="stop2" d="M128 832h768v-768h-768z" />
<glyph unicode="&#xea1f;" glyph-name="backward2" d="M576 800v-320l320 320v-704l-320 320v-320l-352 352z" />
<glyph unicode="&#xea20;" glyph-name="forward3" d="M512 96v320l-320-320v704l320-320v320l352-352z" />
<glyph unicode="&#xea21;" glyph-name="first" d="M128 64v768h128v-352l320 320v-320l320 320v-704l-320 320v-320l-320 320v-352z" />
<glyph unicode="&#xea22;" glyph-name="last" d="M896 832v-768h-128v352l-320-320v320l-320-320v704l320-320v320l320-320v352z" />
<glyph unicode="&#xea23;" glyph-name="previous2" d="M256 64v768h128v-352l320 320v-704l-320 320v-352z" />
<glyph unicode="&#xea24;" glyph-name="next2" d="M768 832v-768h-128v352l-320-320v704l320-320v352z" />
<glyph unicode="&#xea2d;" glyph-name="loop" d="M128 640h640v-192l256 256-256 256v-192h-768v-384h128zM896 256h-640v192l-256-256 256-256v192h768v384h-128z" />
<glyph unicode="&#xea5a;" 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

Binary file not shown.
Binary file not shown.
+457
View File
@@ -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 );
+3067
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 514 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

+856
View File
@@ -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 );
+66
View File
@@ -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 &amp; 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 &amp; 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 &amp; 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>
+72
View File
@@ -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 &amp; 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 &amp; 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 &amp; 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>
+89
View File
@@ -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 );
+15591
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
Binary file not shown.
+262
View File
@@ -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
/******************************************************************************/
Binary file not shown.
+169
View File
@@ -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
+2018
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -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"
}
]
}
+421
View File
@@ -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 );
+29
View File
@@ -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 );
Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+223
View File
@@ -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 );
File diff suppressed because it is too large Load Diff
Binary file not shown.
+296
View File
@@ -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>
+117
View File
@@ -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 );
+59
View File
@@ -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;
}
BIN
View File
Binary file not shown.
+1927
View File
File diff suppressed because it is too large Load Diff
+3095
View File
File diff suppressed because it is too large Load Diff
+109
View File
@@ -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 );
}
+71
View File
@@ -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 = '&nbsp; &nbsp; &nbsp; OK &nbsp; &nbsp; &nbsp;';
};
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 );
+296
View File
@@ -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;
File diff suppressed because it is too large Load Diff
+307
View File
@@ -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>
);
};
+117
View File
@@ -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>
);
};
+140
View File
@@ -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);
}
+723
View File
@@ -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>
);
};
+292
View File
@@ -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>
);
};
+148
View File
@@ -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>
);
};
+312
View File
@@ -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>
);
+611
View File
@@ -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>
);
};
+182
View File
@@ -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>
);
};
+226
View File
@@ -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)}&quote=${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);
};
+125
View File
@@ -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>
);
+227
View File
@@ -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>
);
};
+456
View File
@@ -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}
/>
</>
);
};
+312
View File
@@ -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>
);
};
+53
View File
@@ -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>
);
};
+726
View File
@@ -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>
);
};
+120
View File
@@ -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>
);
};
File diff suppressed because it is too large Load Diff
+117
View File
@@ -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;
}
+55
View File
@@ -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;
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 MiB

Vendored
+36
View File
@@ -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;
}
+266
View File
@@ -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>
+21
View File
@@ -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>
);
+5
View File
@@ -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": []
}
+2752
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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"
}
}
+25
View File
@@ -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=
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
.env
*.log
+321
View File
@@ -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 &amp; 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 &amp; 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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 757 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Some files were not shown because too many files have changed in this diff Show More