AdityaAdaki commited on
Commit
e1cef6b
·
1 Parent(s): 73298ec

Initial commit

Browse files
Files changed (8) hide show
  1. .gitignore +25 -0
  2. Dockerfile +22 -0
  3. README.md +32 -5
  4. app.py +225 -0
  5. requirements.txt +3 -0
  6. static/css/style.css +889 -0
  7. static/js/main.js +612 -0
  8. templates/index.html +114 -0
.gitignore ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.so
5
+ .Python
6
+ env/
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ lib/
14
+ lib64/
15
+ parts/
16
+ sdist/
17
+ var/
18
+ *.egg-info/
19
+ .installed.cfg
20
+ *.egg
21
+ .env
22
+ .venv
23
+ venv/
24
+ ENV/
25
+ .DS_Store
Dockerfile ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ WORKDIR /code
4
+
5
+ # Install system dependencies
6
+ RUN apt-get update && \
7
+ apt-get install -y --no-install-recommends \
8
+ build-essential \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ # Copy requirements first to leverage Docker cache
12
+ COPY requirements.txt .
13
+ RUN pip install --no-cache-dir -r requirements.txt
14
+
15
+ # Copy the rest of the application
16
+ COPY . .
17
+
18
+ # Make port 7860 available to the world outside this container
19
+ EXPOSE 7860
20
+
21
+ # Run the application
22
+ CMD ["python", "app.py"]
README.md CHANGED
@@ -1,10 +1,37 @@
1
  ---
2
- title: MusicPlayer ThreeJS
3
- emoji: 🐨
4
- colorFrom: gray
5
  colorTo: blue
6
  sdk: docker
7
- pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Soundscape - 3D Music Visualizer
3
+ emoji: 🎵
4
+ colorFrom: green
5
  colorTo: blue
6
  sdk: docker
7
+ app_port: 7860
8
  ---
9
 
10
+ # Soundscape - 3D Music Visualizer
11
+
12
+ An interactive 3D music visualization web application built with Flask, Three.js, and Web Audio API. Upload your music and watch it come to life with stunning 3D visualizations.
13
+
14
+ ## Features
15
+
16
+ - Multiple visualization modes (Circular Bars, Sphere, Particles)
17
+ - Audio file upload support (MP3, WAV, OGG, FLAC)
18
+ - Metadata extraction and display
19
+ - Playlist management
20
+ - Dark/Light theme support
21
+ - Responsive design
22
+ - Real-time audio visualization
23
+
24
+ ## Technical Stack
25
+
26
+ - Backend: Flask
27
+ - Frontend: Three.js, Web Audio API
28
+ - Audio Processing: Mutagen
29
+ - Styling: CSS3 with modern features
30
+
31
+ ## Usage
32
+
33
+ 1. Click the upload button or drag and drop audio files
34
+ 2. Select visualization type from the dropdown
35
+ 3. Use player controls to manage playback
36
+ 4. Toggle between different visualization modes
37
+ 5. Enjoy the 3D visualization of your music!
app.py ADDED
@@ -0,0 +1,225 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Flask, render_template, request, jsonify, send_from_directory
2
+ import os
3
+ import tempfile
4
+ import atexit
5
+ import shutil
6
+ from datetime import datetime, timedelta
7
+ import threading
8
+ import time
9
+ from mutagen import File
10
+ from mutagen.easyid3 import EasyID3
11
+ import json
12
+ import mutagen.mp3
13
+ import mutagen.flac
14
+ import mutagen.oggvorbis
15
+ from werkzeug.utils import secure_filename
16
+
17
+ app = Flask(__name__)
18
+ # Create a temporary directory for uploads
19
+ TEMP_UPLOAD_DIR = tempfile.mkdtemp()
20
+ app.config['UPLOAD_FOLDER'] = TEMP_UPLOAD_DIR
21
+ app.config['MAX_CONTENT_LENGTH'] = 200 * 1024 * 1024 # 200MB max file size
22
+ app.config['MAX_FILES'] = 10 # Maximum number of files that can be uploaded at once
23
+
24
+ ALLOWED_EXTENSIONS = {'mp3', 'wav', 'ogg', 'flac'} # Added FLAC support
25
+ FILE_LIFETIME = timedelta(hours=1) # Files will be deleted after 1 hour
26
+
27
+ # Store file creation times
28
+ file_timestamps = {}
29
+
30
+ def allowed_file(filename):
31
+ return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
32
+
33
+ # Add function to extract metadata
34
+ def extract_metadata(filepath):
35
+ try:
36
+ audio = File(filepath)
37
+ if audio is None:
38
+ return {}
39
+
40
+ metadata = {
41
+ 'duration': round(audio.info.length) if hasattr(audio.info, 'length') else None,
42
+ 'bitrate': round(audio.info.bitrate / 1000) if hasattr(audio.info, 'bitrate') else None,
43
+ 'sample_rate': audio.info.sample_rate if hasattr(audio.info, 'sample_rate') else None,
44
+ 'channels': audio.info.channels if hasattr(audio.info, 'channels') else None,
45
+ 'title': None,
46
+ 'artist': 'Unknown Artist',
47
+ 'album': None,
48
+ 'genre': None,
49
+ 'date': None
50
+ }
51
+
52
+ # Get the original filename without timestamp prefix and extension
53
+ original_filename = os.path.splitext(os.path.basename(filepath))[0]
54
+ if '_' in original_filename:
55
+ # Remove timestamp prefix (YYYYMMDD_HHMMSS_)
56
+ original_filename = '_'.join(original_filename.split('_')[2:])
57
+
58
+ try:
59
+ # Handle MP3 files
60
+ if isinstance(audio, mutagen.mp3.MP3):
61
+ try:
62
+ id3 = EasyID3(filepath)
63
+ metadata.update({
64
+ 'title': id3.get('title', [''])[0],
65
+ 'artist': id3.get('artist', ['Unknown Artist'])[0],
66
+ 'album': id3.get('album', [''])[0],
67
+ 'genre': id3.get('genre', [''])[0],
68
+ 'date': id3.get('date', [''])[0]
69
+ })
70
+ except:
71
+ metadata['title'] = original_filename
72
+
73
+ # Handle FLAC files
74
+ elif isinstance(audio, mutagen.flac.FLAC):
75
+ metadata.update({
76
+ 'title': audio.tags.get('TITLE', [''])[0] if audio.tags and 'TITLE' in audio.tags else None,
77
+ 'artist': audio.tags.get('ARTIST', ['Unknown Artist'])[0] if audio.tags and 'ARTIST' in audio.tags else 'Unknown Artist',
78
+ 'album': audio.tags.get('ALBUM', [''])[0] if audio.tags and 'ALBUM' in audio.tags else None,
79
+ 'genre': audio.tags.get('GENRE', [''])[0] if audio.tags and 'GENRE' in audio.tags else None,
80
+ 'date': audio.tags.get('DATE', [''])[0] if audio.tags and 'DATE' in audio.tags else None
81
+ })
82
+
83
+ # Handle OGG files
84
+ elif isinstance(audio, mutagen.oggvorbis.OggVorbis):
85
+ metadata.update({
86
+ 'title': audio.get('title', [''])[0] if 'title' in audio else None,
87
+ 'artist': audio.get('artist', ['Unknown Artist'])[0] if 'artist' in audio else 'Unknown Artist',
88
+ 'album': audio.get('album', [''])[0] if 'album' in audio else None,
89
+ 'genre': audio.get('genre', [''])[0] if 'genre' in audio else None,
90
+ 'date': audio.get('date', [''])[0] if 'date' in audio else None
91
+ })
92
+
93
+ # If title is not found or empty, use original filename
94
+ if not metadata['title']:
95
+ metadata['title'] = original_filename
96
+
97
+ except Exception as e:
98
+ app.logger.error(f"Error reading tags: {str(e)}")
99
+ metadata['title'] = original_filename
100
+
101
+ return metadata
102
+ except Exception as e:
103
+ app.logger.error(f"Error extracting metadata: {str(e)}")
104
+ return {
105
+ 'title': os.path.splitext(os.path.basename(filepath))[0].split('_', 2)[-1],
106
+ 'artist': 'Unknown Artist'
107
+ }
108
+
109
+ @app.route('/favicon.ico')
110
+ def favicon():
111
+ return send_from_directory('static', 'favicon.ico', mimetype='image/x-icon')
112
+
113
+ @app.route('/')
114
+ def index():
115
+ return render_template('index.html', title="Soundscape - 3D Music Visualizer")
116
+
117
+ def cleanup_old_files():
118
+ while True:
119
+ current_time = datetime.now()
120
+ files_to_delete = []
121
+
122
+ # Check all files
123
+ for filename, timestamp in file_timestamps.items():
124
+ if current_time - timestamp > FILE_LIFETIME:
125
+ filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
126
+ try:
127
+ if os.path.exists(filepath):
128
+ os.remove(filepath)
129
+ files_to_delete.append(filename)
130
+ except Exception as e:
131
+ app.logger.error(f"Error deleting file {filename}: {str(e)}")
132
+
133
+ # Remove deleted files from timestamps
134
+ for filename in files_to_delete:
135
+ file_timestamps.pop(filename, None)
136
+
137
+ time.sleep(300) # Check every 5 minutes
138
+
139
+ # Start cleanup thread
140
+ cleanup_thread = threading.Thread(target=cleanup_old_files, daemon=True)
141
+ cleanup_thread.start()
142
+
143
+ @app.errorhandler(413)
144
+ def request_entity_too_large(error):
145
+ return jsonify({
146
+ 'success': False,
147
+ 'error': 'File too large. Maximum total size is 200MB'
148
+ }), 413
149
+
150
+ @app.route('/upload', methods=['POST'])
151
+ def upload_file():
152
+ if 'files[]' not in request.files:
153
+ return jsonify({'success': False, 'error': 'No files uploaded'}), 400
154
+
155
+ files = request.files.getlist('files[]')
156
+ if not files or all(file.filename == '' for file in files):
157
+ return jsonify({'success': False, 'error': 'No selected files'}), 400
158
+
159
+ if len(files) > app.config['MAX_FILES']:
160
+ return jsonify({
161
+ 'success': False,
162
+ 'error': f"Maximum {app.config['MAX_FILES']} files can be uploaded at once"
163
+ }), 400
164
+
165
+ results = []
166
+ for file in files:
167
+ if file and allowed_file(file.filename):
168
+ try:
169
+ filename = secure_filename(file.filename)
170
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S_')
171
+ filename = timestamp + filename
172
+
173
+ filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
174
+ file.save(filepath)
175
+
176
+ file_timestamps[filename] = datetime.now()
177
+
178
+ if not os.path.exists(filepath):
179
+ results.append({
180
+ 'filename': file.filename,
181
+ 'success': False,
182
+ 'error': 'Failed to save file'
183
+ })
184
+ continue
185
+
186
+ # Extract metadata
187
+ metadata = extract_metadata(filepath)
188
+
189
+ results.append({
190
+ 'filename': file.filename,
191
+ 'success': True,
192
+ 'filepath': f'/static/uploads/{filename}',
193
+ 'metadata': metadata
194
+ })
195
+ except Exception as e:
196
+ app.logger.error(f"Upload error for {file.filename}: {str(e)}")
197
+ results.append({
198
+ 'filename': file.filename,
199
+ 'success': False,
200
+ 'error': 'Server error during upload'
201
+ })
202
+ else:
203
+ results.append({
204
+ 'filename': file.filename,
205
+ 'success': False,
206
+ 'error': 'Invalid file type'
207
+ })
208
+
209
+ return jsonify({
210
+ 'success': True,
211
+ 'files': results
212
+ })
213
+
214
+ @app.route('/static/uploads/<filename>')
215
+ def serve_audio(filename):
216
+ return send_from_directory(app.config['UPLOAD_FOLDER'], filename)
217
+
218
+ # Cleanup function to remove temp directory on shutdown
219
+ def cleanup():
220
+ shutil.rmtree(TEMP_UPLOAD_DIR, ignore_errors=True)
221
+
222
+ atexit.register(cleanup)
223
+
224
+ if __name__ == '__main__':
225
+ app.run(host='0.0.0.0', port=7860)
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ flask==2.0.1
2
+ mutagen==1.45.1
3
+ Werkzeug==2.0.1
static/css/style.css ADDED
@@ -0,0 +1,889 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --primary-color: #4CAF50;
3
+ --primary-hover: #45a049;
4
+ --bg-color: #0a0a0a;
5
+ --surface-color: rgba(15, 23, 42, 0.8);
6
+ --text-primary: #ffffff;
7
+ --text-secondary: rgba(255, 255, 255, 0.7);
8
+ --error-color: #ff4444;
9
+ }
10
+
11
+ /* Light theme variables */
12
+ [data-theme="light"] {
13
+ --bg-color: #f5f5f5;
14
+ --surface-color: rgba(255, 255, 255, 0.9);
15
+ --text-primary: #1a1a1a;
16
+ --text-secondary: rgba(0, 0, 0, 0.7);
17
+ }
18
+
19
+ .app-header {
20
+ position: fixed;
21
+ top: 0;
22
+ left: 0;
23
+ right: 0;
24
+ display: flex;
25
+ justify-content: space-between;
26
+ align-items: center;
27
+ padding: 0.75rem 1.5rem;
28
+ background: var(--surface-color);
29
+ backdrop-filter: blur(12px);
30
+ z-index: 100;
31
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
32
+ }
33
+
34
+ .logo {
35
+ display: flex;
36
+ align-items: center;
37
+ gap: 0.5rem;
38
+ font-size: 1.5rem;
39
+ font-weight: 700;
40
+ color: var(--primary-color);
41
+ }
42
+
43
+ .controls-container {
44
+ position: fixed;
45
+ bottom: 2rem;
46
+ left: 50%;
47
+ transform: translateX(-50%);
48
+ width: 90%;
49
+ max-width: 800px;
50
+ z-index: 100;
51
+ background: var(--surface-color);
52
+ backdrop-filter: blur(12px);
53
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
54
+ padding: 1.5rem;
55
+ }
56
+
57
+ .upload-content {
58
+ display: flex;
59
+ flex-direction: column;
60
+ align-items: center;
61
+ gap: 1rem;
62
+ }
63
+
64
+ .upload-content i {
65
+ font-size: 3rem;
66
+ color: var(--primary-color);
67
+ }
68
+
69
+ .upload-text h3 {
70
+ margin: 0;
71
+ font-size: 1.2rem;
72
+ font-weight: 600;
73
+ }
74
+
75
+ .upload-text p {
76
+ margin: 0.5rem 0;
77
+ color: var(--text-secondary);
78
+ }
79
+
80
+ .file-types {
81
+ font-size: 0.8rem;
82
+ color: var(--text-secondary);
83
+ }
84
+
85
+ .error-toast {
86
+ position: fixed;
87
+ top: 90px;
88
+ left: 50%;
89
+ transform: translateX(-50%) translateY(-150%);
90
+ background: rgba(255, 68, 68, 0.95);
91
+ color: white;
92
+ padding: 0.75rem 2rem;
93
+ border-radius: 8px;
94
+ box-shadow: 0 4px 15px rgba(255, 68, 68, 0.2);
95
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
96
+ z-index: 1001;
97
+ text-align: center;
98
+ max-width: 90%;
99
+ pointer-events: none;
100
+ font-size: 0.9rem;
101
+ font-weight: 500;
102
+ backdrop-filter: blur(8px);
103
+ border: 1px solid rgba(255, 255, 255, 0.1);
104
+ opacity: 0;
105
+ }
106
+
107
+ .error-toast.visible {
108
+ transform: translateX(-50%) translateY(0);
109
+ opacity: 1;
110
+ }
111
+
112
+ select {
113
+ padding: 8px 12px;
114
+ border-radius: 8px;
115
+ background: rgba(255, 255, 255, 0.1);
116
+ border: 1px solid rgba(255, 255, 255, 0.2);
117
+ color: white;
118
+ cursor: pointer;
119
+ font-size: 14px;
120
+ -webkit-appearance: none;
121
+ -moz-appearance: none;
122
+ appearance: none;
123
+ padding-right: 30px;
124
+ background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
125
+ background-repeat: no-repeat;
126
+ background-position: right 8px center;
127
+ background-size: 16px;
128
+ }
129
+
130
+ select:focus {
131
+ outline: none;
132
+ border-color: #4CAF50;
133
+ }
134
+
135
+ select option {
136
+ background-color: #1a1a1a;
137
+ color: white;
138
+ padding: 8px;
139
+ }
140
+
141
+ select::-ms-expand {
142
+ display: none;
143
+ }
144
+
145
+ input[type="range"] {
146
+ /* ... existing properties ... */
147
+ -webkit-appearance: none;
148
+ -moz-appearance: none;
149
+ appearance: none; /* Add standard property */
150
+ /* ... rest of properties ... */
151
+ }
152
+
153
+ .track-title {
154
+ display: flex;
155
+ justify-content: space-between;
156
+ align-items: center;
157
+ font-weight: 500;
158
+ color: var(--text-primary);
159
+ margin-bottom: 4px;
160
+ }
161
+
162
+ .track-duration {
163
+ font-size: 0.85rem;
164
+ color: var(--text-secondary);
165
+ margin-left: 8px;
166
+ }
167
+
168
+ .track-metadata {
169
+ font-size: 0.85rem;
170
+ color: var(--text-secondary);
171
+ overflow: hidden;
172
+ text-overflow: ellipsis;
173
+ white-space: nowrap;
174
+ }
175
+
176
+ .playlist-item {
177
+ padding: 8px 12px;
178
+ cursor: pointer;
179
+ border-radius: 4px;
180
+ transition: background-color 0.2s;
181
+ }
182
+
183
+ .playlist-item:hover {
184
+ background: rgba(255, 255, 255, 0.1);
185
+ }
186
+
187
+ .playlist-item.active {
188
+ background: rgba(76, 175, 80, 0.3);
189
+ }
190
+
191
+ .music-controls {
192
+ display: flex;
193
+ justify-content: center;
194
+ align-items: center;
195
+ gap: 16px;
196
+ margin-top: 12px;
197
+ }
198
+
199
+ .control-btn {
200
+ background: transparent;
201
+ border: none;
202
+ color: white;
203
+ padding: 8px;
204
+ min-width: auto;
205
+ border-radius: 50%;
206
+ cursor: pointer;
207
+ transition: all 0.2s ease;
208
+ }
209
+
210
+ .control-btn:hover {
211
+ background: rgba(255, 255, 255, 0.1);
212
+ transform: scale(1.1);
213
+ }
214
+
215
+ .control-btn:active {
216
+ transform: scale(0.95);
217
+ }
218
+
219
+ .play-pause-btn {
220
+ background: #4CAF50;
221
+ width: 40px;
222
+ height: 40px;
223
+ display: flex;
224
+ align-items: center;
225
+ justify-content: center;
226
+ }
227
+
228
+ .play-pause-btn:hover {
229
+ background: #45a049;
230
+ }
231
+
232
+ .previous-btn,
233
+ .next-btn {
234
+ width: 32px;
235
+ height: 32px;
236
+ display: flex;
237
+ align-items: center;
238
+ justify-content: center;
239
+ }
240
+
241
+ .control-btn i {
242
+ font-size: 1.2em;
243
+ }
244
+
245
+ .play-pause-btn i {
246
+ font-size: 1.4em;
247
+ }
248
+
249
+ .control-btn:disabled {
250
+ opacity: 0.5;
251
+ cursor: not-allowed;
252
+ pointer-events: none;
253
+ }
254
+
255
+ /* Update volume control styles */
256
+ .volume-control {
257
+ position: relative;
258
+ display: flex;
259
+ align-items: center;
260
+ gap: 0.5rem;
261
+ margin-left: auto;
262
+ min-width: 120px;
263
+ }
264
+
265
+ .volume-btn {
266
+ background: transparent;
267
+ border: none;
268
+ padding: 8px;
269
+ min-width: auto;
270
+ color: var(--text-primary);
271
+ }
272
+
273
+ .volume-slider-container {
274
+ position: relative;
275
+ width: 80px;
276
+ height: 4px;
277
+ background: rgba(255, 255, 255, 0.2);
278
+ border-radius: 2px;
279
+ }
280
+
281
+ .volume-progress {
282
+ position: absolute;
283
+ left: 0;
284
+ top: 0;
285
+ height: 100%;
286
+ background: var(--primary-color);
287
+ border-radius: 2px;
288
+ pointer-events: none;
289
+ width: 50%;
290
+ }
291
+
292
+ #volume {
293
+ position: absolute;
294
+ width: 100%;
295
+ height: 100%;
296
+ opacity: 0;
297
+ cursor: pointer;
298
+ }
299
+
300
+ /* Add tooltip styles */
301
+ .tooltip {
302
+ position: fixed;
303
+ background: rgba(0, 0, 0, 0.8);
304
+ color: white;
305
+ padding: 0.5rem 1rem;
306
+ border-radius: 0.25rem;
307
+ font-size: 0.8rem;
308
+ pointer-events: none;
309
+ opacity: 0;
310
+ transition: opacity 0.2s;
311
+ z-index: 1002;
312
+ }
313
+
314
+ .tooltip.visible {
315
+ opacity: 1;
316
+ }
317
+
318
+ .playlist-container {
319
+ background: var(--surface-color);
320
+ border-radius: 1rem;
321
+ margin: 0;
322
+ max-height: 100%;
323
+ overflow: hidden;
324
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
325
+ }
326
+
327
+ .playlist-header {
328
+ display: flex;
329
+ justify-content: space-between;
330
+ align-items: center;
331
+ padding: 1rem;
332
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
333
+ }
334
+
335
+ .playlist-header h3 {
336
+ margin: 0;
337
+ font-size: 1.2rem;
338
+ font-weight: 600;
339
+ color: var(--text-primary);
340
+ }
341
+
342
+ .tracks-list {
343
+ max-height: 300px;
344
+ overflow-y: auto;
345
+ padding: 0.5rem;
346
+ }
347
+
348
+ .playlist-item {
349
+ padding: 0.75rem;
350
+ border-radius: 0.5rem;
351
+ cursor: pointer;
352
+ transition: background-color 0.2s;
353
+ }
354
+
355
+ .playlist-item:hover {
356
+ background: rgba(255, 255, 255, 0.1);
357
+ }
358
+
359
+ .playlist-item.active {
360
+ background: rgba(76, 175, 80, 0.2);
361
+ }
362
+
363
+ .track-info {
364
+ display: flex;
365
+ align-items: center;
366
+ gap: 1rem;
367
+ }
368
+
369
+ .track-number {
370
+ color: var(--text-secondary);
371
+ min-width: 2rem;
372
+ text-align: right;
373
+ }
374
+
375
+ .track-content {
376
+ flex: 1;
377
+ }
378
+
379
+ .track-title {
380
+ display: flex;
381
+ justify-content: space-between;
382
+ align-items: center;
383
+ margin-bottom: 0.25rem;
384
+ }
385
+
386
+ .track-duration {
387
+ color: var(--text-secondary);
388
+ font-size: 0.9rem;
389
+ }
390
+
391
+ .track-metadata {
392
+ color: var(--text-secondary);
393
+ font-size: 0.9rem;
394
+ }
395
+
396
+ .track-controls {
397
+ opacity: 0;
398
+ transition: opacity 0.2s;
399
+ }
400
+
401
+ .playlist-item:hover .track-controls,
402
+ .playlist-item.active .track-controls {
403
+ opacity: 1;
404
+ }
405
+
406
+ /* Add custom scrollbar for the tracks list */
407
+ .tracks-list::-webkit-scrollbar {
408
+ width: 6px;
409
+ }
410
+
411
+ .tracks-list::-webkit-scrollbar-track {
412
+ background: rgba(0, 0, 0, 0.1);
413
+ border-radius: 3px;
414
+ }
415
+
416
+ .tracks-list::-webkit-scrollbar-thumb {
417
+ background: rgba(255, 255, 255, 0.2);
418
+ border-radius: 3px;
419
+ }
420
+
421
+ .tracks-list::-webkit-scrollbar-thumb:hover {
422
+ background: rgba(255, 255, 255, 0.3);
423
+ }
424
+
425
+ @media screen and (max-width: 768px) {
426
+ .app-header {
427
+ padding: 0.75rem 1rem;
428
+ }
429
+
430
+ .logo span {
431
+ display: none; /* Hide logo text on mobile */
432
+ }
433
+
434
+ .main-content {
435
+ position: fixed;
436
+ top: auto;
437
+ right: 0;
438
+ bottom: 140px; /* Position above player controls */
439
+ width: 100%;
440
+ max-height: 40vh;
441
+ padding: 0 1rem;
442
+ z-index: 10;
443
+ }
444
+
445
+ .upload-area {
446
+ margin: 0.5rem 0;
447
+ padding: 1rem;
448
+ }
449
+
450
+ .controls-container {
451
+ bottom: 0;
452
+ width: 100%;
453
+ border-radius: 1rem 1rem 0 0;
454
+ padding: 1rem;
455
+ }
456
+
457
+ .music-controls {
458
+ gap: 12px;
459
+ }
460
+
461
+ .control-btn {
462
+ padding: 6px;
463
+ }
464
+
465
+ .volume-control {
466
+ display: none; /* Hide volume control on mobile */
467
+ }
468
+
469
+ .playlist-container {
470
+ border-radius: 1rem;
471
+ max-height: 100%;
472
+ }
473
+
474
+ .tracks-list {
475
+ max-height: calc(40vh - 60px); /* Adjust based on playlist header height */
476
+ }
477
+
478
+ .track-metadata {
479
+ max-width: 200px; /* Prevent long metadata from breaking layout */
480
+ overflow: hidden;
481
+ text-overflow: ellipsis;
482
+ white-space: nowrap;
483
+ }
484
+ }
485
+
486
+ /* Add specific adjustments for very small screens */
487
+ @media screen and (max-width: 380px) {
488
+ .music-controls {
489
+ gap: 8px;
490
+ }
491
+
492
+ .control-btn {
493
+ width: 28px;
494
+ height: 28px;
495
+ }
496
+
497
+ .play-pause-btn {
498
+ width: 36px;
499
+ height: 36px;
500
+ }
501
+
502
+ .track-title {
503
+ font-size: 0.9rem;
504
+ }
505
+
506
+ .track-metadata {
507
+ font-size: 0.8rem;
508
+ max-width: 150px;
509
+ }
510
+ }
511
+
512
+ /* Add these styles for the now playing info */
513
+ .now-playing-info {
514
+ display: flex;
515
+ justify-content: space-between;
516
+ align-items: center;
517
+ margin-bottom: 8px;
518
+ }
519
+
520
+ .now-playing-text {
521
+ display: flex;
522
+ flex-direction: column;
523
+ overflow: hidden;
524
+ flex: 1;
525
+ margin-right: 16px;
526
+ }
527
+
528
+ .now-playing-title {
529
+ font-size: 1rem;
530
+ font-weight: 500;
531
+ color: var(--text-primary);
532
+ margin-bottom: 4px;
533
+ overflow: hidden;
534
+ text-overflow: ellipsis;
535
+ white-space: nowrap;
536
+ }
537
+
538
+ .now-playing-artist {
539
+ font-size: 0.9rem;
540
+ color: var(--text-secondary);
541
+ overflow: hidden;
542
+ text-overflow: ellipsis;
543
+ white-space: nowrap;
544
+ }
545
+
546
+ .time-display {
547
+ font-size: 0.85rem;
548
+ color: var(--text-secondary);
549
+ white-space: nowrap;
550
+ }
551
+
552
+ /* Update mobile styles */
553
+ @media screen and (max-width: 768px) {
554
+ .now-playing-info {
555
+ flex-direction: column;
556
+ align-items: flex-start;
557
+ gap: 4px;
558
+ }
559
+
560
+ .now-playing-text {
561
+ width: 100%;
562
+ margin-right: 0;
563
+ }
564
+
565
+ .time-display {
566
+ align-self: flex-end;
567
+ }
568
+ }
569
+
570
+ /* Update very small screen styles */
571
+ @media screen and (max-width: 380px) {
572
+ .now-playing-title {
573
+ font-size: 0.9rem;
574
+ }
575
+
576
+ .now-playing-artist {
577
+ font-size: 0.8rem;
578
+ }
579
+ }
580
+
581
+ /* Add these styles at the appropriate location in your CSS file */
582
+
583
+ /* Ensure the canvas container takes full viewport */
584
+ body {
585
+ position: relative;
586
+ width: 100vw;
587
+ height: 100vh;
588
+ margin: 0;
589
+ overflow: hidden;
590
+ }
591
+
592
+ /* Position the THREE.js canvas */
593
+ canvas {
594
+ position: fixed !important;
595
+ top: 0;
596
+ left: 0;
597
+ z-index: 1;
598
+ }
599
+
600
+ /* Adjust the header positioning */
601
+ .app-header {
602
+ /* ... existing styles ... */
603
+ z-index: 100;
604
+ }
605
+
606
+ /* Create a main content area for the playlist */
607
+ .main-content {
608
+ position: fixed;
609
+ top: 80px; /* Adjust based on your header height */
610
+ right: 2rem;
611
+ width: 400px;
612
+ max-height: calc(100vh - 260px); /* Adjust based on your controls height */
613
+ z-index: 10;
614
+ overflow: visible;
615
+ }
616
+
617
+ /* Adjust the playlist container */
618
+ .playlist-container {
619
+ margin: 0;
620
+ max-height: 100%;
621
+ background: var(--surface-color);
622
+ backdrop-filter: blur(12px);
623
+ }
624
+
625
+ /* Adjust the controls container */
626
+ .controls-container {
627
+ position: fixed;
628
+ bottom: 2rem;
629
+ left: 50%;
630
+ transform: translateX(-50%);
631
+ width: 90%;
632
+ max-width: 800px;
633
+ z-index: 100;
634
+ background: var(--surface-color);
635
+ backdrop-filter: blur(12px);
636
+ }
637
+
638
+ /* Adjust the upload area to be inside the playlist */
639
+ .upload-area {
640
+ margin: 1rem;
641
+ }
642
+
643
+ /* Add these styles for the progress bar */
644
+ .progress-bar {
645
+ position: relative;
646
+ width: 100%;
647
+ height: 4px;
648
+ background: rgba(255, 255, 255, 0.1);
649
+ border-radius: 2px;
650
+ margin: 8px 0;
651
+ cursor: pointer;
652
+ }
653
+
654
+ .progress {
655
+ position: absolute;
656
+ left: 0;
657
+ top: 0;
658
+ height: 100%;
659
+ background: var(--primary-color);
660
+ border-radius: 2px;
661
+ pointer-events: none;
662
+ width: 0;
663
+ }
664
+
665
+ .seek-slider {
666
+ position: absolute;
667
+ width: 100%;
668
+ height: 100%;
669
+ opacity: 0;
670
+ cursor: pointer;
671
+ }
672
+
673
+ /* Add styles for the playlist buttons */
674
+ .playlist-btn {
675
+ background: transparent;
676
+ border: none;
677
+ color: var(--text-primary);
678
+ padding: 8px;
679
+ cursor: pointer;
680
+ border-radius: 50%;
681
+ transition: all 0.2s ease;
682
+ }
683
+
684
+ .playlist-btn:hover {
685
+ background: rgba(255, 255, 255, 0.1);
686
+ transform: scale(1.1);
687
+ }
688
+
689
+ .playlist-btn.active {
690
+ color: var(--primary-color);
691
+ }
692
+
693
+ /* Add loading spinner styles */
694
+ .loading {
695
+ display: none;
696
+ align-items: center;
697
+ justify-content: center;
698
+ gap: 1rem;
699
+ padding: 1rem;
700
+ }
701
+
702
+ .loading.visible {
703
+ display: flex;
704
+ }
705
+
706
+ .spinner {
707
+ width: 20px;
708
+ height: 20px;
709
+ border: 2px solid rgba(255, 255, 255, 0.3);
710
+ border-top-color: var(--primary-color);
711
+ border-radius: 50%;
712
+ animation: spin 1s linear infinite;
713
+ }
714
+
715
+ @keyframes spin {
716
+ to {
717
+ transform: rotate(360deg);
718
+ }
719
+ }
720
+
721
+ /* Add styles for dragover state */
722
+ .upload-area.dragover {
723
+ border-color: var(--primary-color);
724
+ background: rgba(76, 175, 80, 0.1);
725
+ }
726
+
727
+ /* Add styles for the main container */
728
+ .container {
729
+ display: flex;
730
+ flex-direction: column;
731
+ height: 100vh;
732
+ padding: 80px 2rem 2rem;
733
+ max-width: 1200px;
734
+ margin: 0 auto;
735
+ box-sizing: border-box;
736
+ }
737
+
738
+ /* Update body styles */
739
+ body {
740
+ margin: 0;
741
+ font-family: 'Inter', sans-serif;
742
+ background: var(--bg-color);
743
+ color: var(--text-primary);
744
+ overflow-x: hidden;
745
+ line-height: 1.5;
746
+ }
747
+
748
+ /* Add styles for buttons */
749
+ button {
750
+ font-family: 'Inter', sans-serif;
751
+ border: none;
752
+ background: none;
753
+ cursor: pointer;
754
+ padding: 0;
755
+ color: inherit;
756
+ }
757
+
758
+ button:disabled {
759
+ opacity: 0.5;
760
+ cursor: not-allowed;
761
+ }
762
+
763
+ /* Hide file input */
764
+ input[type="file"] {
765
+ display: none;
766
+ }
767
+
768
+ /* Add transition for theme changes */
769
+ * {
770
+ transition: background-color 0.3s, color 0.3s;
771
+ }
772
+
773
+ /* Add styles for mobile responsiveness */
774
+ @media (max-width: 768px) {
775
+ .container {
776
+ padding: 60px 1rem 1rem;
777
+ }
778
+
779
+ .controls-container {
780
+ padding: 1rem;
781
+ }
782
+
783
+ .music-controls {
784
+ flex-wrap: wrap;
785
+ gap: 8px;
786
+ }
787
+
788
+ .volume-control {
789
+ width: 100%;
790
+ order: 3;
791
+ }
792
+
793
+ #visualization-type {
794
+ width: 100%;
795
+ order: 4;
796
+ }
797
+ }
798
+
799
+ /* Add styles for the error message */
800
+ #error-message {
801
+ color: var(--error-color);
802
+ text-align: center;
803
+ margin: 8px 0;
804
+ font-size: 0.9rem;
805
+ }
806
+
807
+ /* Add styles for file name display */
808
+ #file-name {
809
+ margin-top: 8px;
810
+ font-size: 0.9rem;
811
+ color: var(--text-secondary);
812
+ text-align: center;
813
+ word-break: break-word;
814
+ }
815
+
816
+ /* Add styles for header buttons */
817
+ .header-controls {
818
+ display: flex;
819
+ align-items: center;
820
+ gap: 8px;
821
+ }
822
+
823
+ .header-btn {
824
+ background: transparent;
825
+ border: none;
826
+ color: var(--text-primary);
827
+ width: 36px;
828
+ height: 36px;
829
+ border-radius: 50%;
830
+ display: flex;
831
+ align-items: center;
832
+ justify-content: center;
833
+ cursor: pointer;
834
+ transition: all 0.2s ease;
835
+ }
836
+
837
+ .header-btn:hover {
838
+ background: rgba(255, 255, 255, 0.1);
839
+ }
840
+
841
+ .header-btn.active {
842
+ color: var(--primary-color);
843
+ background: rgba(76, 175, 80, 0.1);
844
+ }
845
+
846
+ /* Update main content styles */
847
+ .main-content {
848
+ position: fixed;
849
+ top: 80px;
850
+ right: -420px; /* Hide by default */
851
+ width: 400px;
852
+ max-height: calc(100vh - 260px);
853
+ z-index: 10;
854
+ overflow: visible;
855
+ transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
856
+ }
857
+
858
+ .main-content.visible {
859
+ right: 2rem;
860
+ }
861
+
862
+ /* Update upload area styles */
863
+ .upload-area {
864
+ display: none;
865
+ margin: 1rem;
866
+ opacity: 0;
867
+ transform: translateY(-20px);
868
+ transition: all 0.3s ease;
869
+ }
870
+
871
+ .upload-area.visible {
872
+ display: block;
873
+ opacity: 1;
874
+ transform: translateY(0);
875
+ }
876
+
877
+ /* Update playlist container styles */
878
+ .playlist-container {
879
+ display: none;
880
+ opacity: 0;
881
+ transform: translateY(-20px);
882
+ transition: all 0.3s ease;
883
+ }
884
+
885
+ .playlist-container.visible {
886
+ display: block;
887
+ opacity: 1;
888
+ transform: translateY(0);
889
+ }
static/js/main.js ADDED
@@ -0,0 +1,612 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Initialize Three.js scene and renderer
2
+ const scene = new THREE.Scene();
3
+ const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
4
+ const renderer = new THREE.WebGLRenderer({ antialias: true });
5
+ renderer.setSize(window.innerWidth, window.innerHeight);
6
+
7
+ // Set initial background color based on theme
8
+ const savedTheme = localStorage.getItem('theme') || 'dark';
9
+ document.documentElement.setAttribute('data-theme', savedTheme);
10
+ renderer.setClearColor(savedTheme === 'light' ? 0xf5f5f5 : 0x000000);
11
+ document.body.appendChild(renderer.domElement);
12
+
13
+ // Initialize controls
14
+ const controls = new THREE.OrbitControls(camera, renderer.domElement);
15
+ setupOrbitControls();
16
+
17
+ // Initialize other variables
18
+ let audioContext;
19
+ let analyser;
20
+ let audioElement;
21
+ let playlist = [];
22
+ let currentTrackIndex = 0;
23
+ let isPlaying = false;
24
+ let visualizationType = 'bars';
25
+ let visualizers = {
26
+ bars: [],
27
+ sphere: null,
28
+ particles: null
29
+ };
30
+ let isShuffleActive = false;
31
+ let isRepeatActive = false;
32
+
33
+ // Theme button functionality
34
+ const themeBtn = document.querySelector('.theme-btn');
35
+ if (themeBtn) {
36
+ themeBtn.innerHTML = savedTheme === 'light' ?
37
+ '<i class="fas fa-moon"></i>' :
38
+ '<i class="fas fa-sun"></i>';
39
+
40
+ themeBtn.addEventListener('click', () => {
41
+ const currentTheme = document.documentElement.getAttribute('data-theme');
42
+ const newTheme = currentTheme === 'light' ? 'dark' : 'light';
43
+
44
+ document.documentElement.setAttribute('data-theme', newTheme);
45
+ localStorage.setItem('theme', newTheme);
46
+
47
+ themeBtn.innerHTML = newTheme === 'light' ?
48
+ '<i class="fas fa-moon"></i>' :
49
+ '<i class="fas fa-sun"></i>';
50
+
51
+ renderer.setClearColor(newTheme === 'light' ? 0xf5f5f5 : 0x000000);
52
+ });
53
+ }
54
+
55
+ // Initialize audio context
56
+ function initAudio() {
57
+ audioContext = new (window.AudioContext || window.webkitAudioContext)();
58
+ audioElement = new Audio();
59
+ const source = audioContext.createMediaElementSource(audioElement);
60
+ analyser = audioContext.createAnalyser();
61
+ analyser.fftSize = 2048;
62
+ source.connect(analyser);
63
+ analyser.connect(audioContext.destination);
64
+ }
65
+
66
+ // Visualization creation functions
67
+ function createBarsVisualization() {
68
+ const geometry = new THREE.BoxGeometry(0.1, 1, 0.1);
69
+ const material = new THREE.MeshPhongMaterial({ color: 0x4CAF50 });
70
+ const numBars = 64;
71
+
72
+ for (let i = 0; i < numBars; i++) {
73
+ const bar = new THREE.Mesh(geometry, material);
74
+ const angle = (i / numBars) * Math.PI * 2;
75
+ bar.position.x = Math.cos(angle) * 2;
76
+ bar.position.z = Math.sin(angle) * 2;
77
+ scene.add(bar);
78
+ visualizers.bars.push(bar);
79
+ }
80
+
81
+ // Add lighting
82
+ const light = new THREE.PointLight(0xffffff, 1, 100);
83
+ light.position.set(0, 5, 0);
84
+ scene.add(light);
85
+
86
+ const ambientLight = new THREE.AmbientLight(0x404040);
87
+ scene.add(ambientLight);
88
+ }
89
+
90
+ function createSphereVisualization() {
91
+ const geometry = new THREE.SphereGeometry(1, 32, 32);
92
+ const material = new THREE.MeshPhongMaterial({
93
+ color: 0x4CAF50,
94
+ wireframe: true
95
+ });
96
+ visualizers.sphere = new THREE.Mesh(geometry, material);
97
+ scene.add(visualizers.sphere);
98
+
99
+ // Add lighting
100
+ const light = new THREE.PointLight(0xffffff, 1, 100);
101
+ light.position.set(0, 5, 0);
102
+ scene.add(light);
103
+ }
104
+
105
+ function createParticlesVisualization() {
106
+ const particleCount = 1000;
107
+ const geometry = new THREE.BufferGeometry();
108
+ const positions = new Float32Array(particleCount * 3);
109
+
110
+ for (let i = 0; i < particleCount * 3; i += 3) {
111
+ positions[i] = (Math.random() - 0.5) * 5;
112
+ positions[i + 1] = (Math.random() - 0.5) * 5;
113
+ positions[i + 2] = (Math.random() - 0.5) * 5;
114
+ }
115
+
116
+ geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
117
+
118
+ const material = new THREE.PointsMaterial({
119
+ color: 0x4CAF50,
120
+ size: 0.05,
121
+ transparent: true
122
+ });
123
+
124
+ visualizers.particles = new THREE.Points(geometry, material);
125
+ scene.add(visualizers.particles);
126
+ }
127
+
128
+ // Visualization update functions
129
+ function updateBarsVisualization(dataArray) {
130
+ const multiplier = 0.01;
131
+ visualizers.bars.forEach((bar, i) => {
132
+ const value = dataArray[i] * multiplier;
133
+ bar.scale.y = value + 0.1;
134
+ bar.position.y = value / 2;
135
+ });
136
+ }
137
+
138
+ function updateSphereVisualization(dataArray) {
139
+ if (!visualizers.sphere) return;
140
+
141
+ const averageFrequency = dataArray.reduce((a, b) => a + b) / dataArray.length;
142
+ const scale = 1 + (averageFrequency * 0.003);
143
+ visualizers.sphere.scale.set(scale, scale, scale);
144
+ visualizers.sphere.rotation.y += 0.01;
145
+ }
146
+
147
+ function updateParticlesVisualization(dataArray) {
148
+ if (!visualizers.particles) return;
149
+
150
+ const positions = visualizers.particles.geometry.attributes.position.array;
151
+ const multiplier = 0.02;
152
+
153
+ for (let i = 0; i < positions.length; i += 3) {
154
+ const value = dataArray[i / 3] * multiplier;
155
+ const distance = Math.sqrt(
156
+ positions[i] * positions[i] +
157
+ positions[i + 1] * positions[i + 1] +
158
+ positions[i + 2] * positions[i + 2]
159
+ );
160
+
161
+ positions[i] *= 1 + (value / distance);
162
+ positions[i + 1] *= 1 + (value / distance);
163
+ positions[i + 2] *= 1 + (value / distance);
164
+ }
165
+
166
+ visualizers.particles.geometry.attributes.position.needsUpdate = true;
167
+ visualizers.particles.rotation.y += 0.001;
168
+ }
169
+
170
+ // Animation loop
171
+ function animate() {
172
+ requestAnimationFrame(animate);
173
+ controls.update();
174
+
175
+ if (analyser && isPlaying) {
176
+ const dataArray = new Uint8Array(analyser.frequencyBinCount);
177
+ analyser.getByteFrequencyData(dataArray);
178
+ updateVisualization(dataArray);
179
+ }
180
+
181
+ renderer.render(scene, camera);
182
+ }
183
+
184
+ // Start animation
185
+ animate();
186
+
187
+ // Event listeners for window resize
188
+ window.addEventListener('resize', () => {
189
+ camera.aspect = window.innerWidth / window.innerHeight;
190
+ camera.updateProjectionMatrix();
191
+ renderer.setSize(window.innerWidth, window.innerHeight);
192
+ });
193
+
194
+ // Initialize the application
195
+ document.addEventListener('DOMContentLoaded', () => {
196
+ setupUploadHandlers();
197
+ setupPlayerControls();
198
+ setupToggleHandlers();
199
+ createVisualization();
200
+ });
201
+
202
+ // Export necessary functions and variables
203
+ window.playTrack = playTrack;
204
+ window.createPlaylist = createPlaylist;
205
+ window.updateNowPlayingInfo = updateNowPlayingInfo;
206
+
207
+ // Setup orbit controls
208
+ function setupOrbitControls() {
209
+ controls.enableDamping = true;
210
+ controls.dampingFactor = 0.05;
211
+ controls.enableZoom = true;
212
+ controls.autoRotate = true;
213
+ controls.autoRotateSpeed = 2.0;
214
+ camera.position.z = 5;
215
+ }
216
+
217
+ // Handle file uploads
218
+ function setupUploadHandlers() {
219
+ const uploadArea = document.getElementById('upload-area');
220
+ const fileInput = document.getElementById('audio-upload');
221
+ const errorToast = document.querySelector('.error-toast');
222
+
223
+ uploadArea.addEventListener('dragover', (e) => {
224
+ e.preventDefault();
225
+ uploadArea.classList.add('dragover');
226
+ });
227
+
228
+ uploadArea.addEventListener('dragleave', () => {
229
+ uploadArea.classList.remove('dragover');
230
+ });
231
+
232
+ uploadArea.addEventListener('drop', (e) => {
233
+ e.preventDefault();
234
+ uploadArea.classList.remove('dragover');
235
+ handleFiles(e.dataTransfer.files);
236
+ });
237
+
238
+ uploadArea.addEventListener('click', () => {
239
+ fileInput.click();
240
+ });
241
+
242
+ fileInput.addEventListener('change', (e) => {
243
+ handleFiles(e.target.files);
244
+ });
245
+
246
+ // Setup shuffle and repeat buttons
247
+ const shuffleBtn = document.querySelector('.shuffle-btn');
248
+ const repeatBtn = document.querySelector('.repeat-btn');
249
+
250
+ shuffleBtn?.addEventListener('click', () => {
251
+ isShuffleActive = !isShuffleActive;
252
+ shuffleBtn.classList.toggle('active');
253
+ });
254
+
255
+ repeatBtn?.addEventListener('click', () => {
256
+ isRepeatActive = !isRepeatActive;
257
+ repeatBtn.classList.toggle('active');
258
+ });
259
+ }
260
+
261
+ // Handle the uploaded files
262
+ async function handleFiles(files) {
263
+ if (!audioContext) initAudio();
264
+
265
+ const formData = new FormData();
266
+ Array.from(files).forEach(file => {
267
+ formData.append('files[]', file);
268
+ });
269
+
270
+ try {
271
+ const response = await fetch('/upload', {
272
+ method: 'POST',
273
+ body: formData
274
+ });
275
+
276
+ const data = await response.json();
277
+
278
+ if (data.success) {
279
+ playlist = data.files.map(file => ({
280
+ name: file.filename,
281
+ url: file.filepath,
282
+ metadata: file.metadata
283
+ }));
284
+
285
+ createPlaylist();
286
+ if (playlist.length > 0) {
287
+ playTrack(0);
288
+ }
289
+ } else {
290
+ showError(data.error);
291
+ }
292
+ } catch (error) {
293
+ showError('Error uploading files');
294
+ console.error('Upload error:', error);
295
+ }
296
+ }
297
+
298
+ // Show error message
299
+ function showError(message) {
300
+ const errorToast = document.querySelector('.error-toast');
301
+ errorToast.textContent = message;
302
+ errorToast.classList.add('visible');
303
+ setTimeout(() => {
304
+ errorToast.classList.remove('visible');
305
+ }, 3000);
306
+ }
307
+
308
+ // Setup player controls
309
+ function setupPlayerControls() {
310
+ const playPauseBtn = document.getElementById('play-pause');
311
+ const prevBtn = document.querySelector('.previous-btn');
312
+ const nextBtn = document.querySelector('.next-btn');
313
+ const volumeSlider = document.getElementById('volume');
314
+ const seekSlider = document.querySelector('.seek-slider');
315
+ const visualizationSelect = document.getElementById('visualization-type');
316
+
317
+ playPauseBtn.addEventListener('click', togglePlayPause);
318
+ prevBtn.addEventListener('click', playPrevious);
319
+ nextBtn.addEventListener('click', playNext);
320
+ volumeSlider.addEventListener('input', updateVolume);
321
+ seekSlider.addEventListener('input', seekTo);
322
+
323
+ visualizationSelect.addEventListener('change', (e) => {
324
+ visualizationType = e.target.value;
325
+ createVisualization();
326
+ });
327
+
328
+ // Add keyboard controls
329
+ document.addEventListener('keydown', (e) => {
330
+ if (e.code === 'Space') {
331
+ e.preventDefault();
332
+ togglePlayPause();
333
+ } else if (e.code === 'ArrowLeft') {
334
+ playPrevious();
335
+ } else if (e.code === 'ArrowRight') {
336
+ playNext();
337
+ }
338
+ });
339
+
340
+ // Update time display
341
+ if (audioElement) {
342
+ audioElement.addEventListener('timeupdate', updateTimeDisplay);
343
+ audioElement.addEventListener('ended', handleTrackEnd);
344
+ }
345
+ }
346
+
347
+ // Toggle play/pause
348
+ function togglePlayPause() {
349
+ if (!audioElement) return;
350
+
351
+ if (audioElement.paused) {
352
+ audioElement.play();
353
+ isPlaying = true;
354
+ document.getElementById('play-pause').innerHTML = '<i class="fas fa-pause"></i>';
355
+ } else {
356
+ audioElement.pause();
357
+ isPlaying = false;
358
+ document.getElementById('play-pause').innerHTML = '<i class="fas fa-play"></i>';
359
+ }
360
+ }
361
+
362
+ // Play previous track
363
+ function playPrevious() {
364
+ if (playlist.length === 0) return;
365
+ let newIndex = currentTrackIndex - 1;
366
+ if (newIndex < 0) newIndex = playlist.length - 1;
367
+ playTrack(newIndex);
368
+ }
369
+
370
+ // Play next track
371
+ function playNext() {
372
+ if (playlist.length === 0) return;
373
+ let newIndex = currentTrackIndex + 1;
374
+ if (newIndex >= playlist.length) newIndex = 0;
375
+ playTrack(newIndex);
376
+ }
377
+
378
+ // Update volume
379
+ function updateVolume(e) {
380
+ if (audioElement) {
381
+ audioElement.volume = e.target.value;
382
+ const volumeProgress = document.querySelector('.volume-progress');
383
+ volumeProgress.style.width = `${e.target.value * 100}%`;
384
+ }
385
+ }
386
+
387
+ // Seek to position
388
+ function seekTo(e) {
389
+ if (audioElement && audioElement.duration) {
390
+ const time = (e.target.value / 100) * audioElement.duration;
391
+ audioElement.currentTime = time;
392
+ }
393
+ }
394
+
395
+ // Update time display
396
+ function updateTimeDisplay() {
397
+ if (!audioElement) return;
398
+
399
+ const currentTime = document.querySelector('.current-time');
400
+ const totalTime = document.querySelector('.total-time');
401
+ const seekSlider = document.querySelector('.seek-slider');
402
+ const progress = document.querySelector('.progress');
403
+
404
+ const current = formatTime(audioElement.currentTime);
405
+ const total = formatTime(audioElement.duration);
406
+ const progressPercent = (audioElement.currentTime / audioElement.duration) * 100;
407
+
408
+ currentTime.textContent = current;
409
+ totalTime.textContent = total;
410
+ seekSlider.value = progressPercent;
411
+ progress.style.width = `${progressPercent}%`;
412
+ }
413
+
414
+ // Format time in MM:SS
415
+ function formatTime(seconds) {
416
+ if (!seconds || isNaN(seconds)) return '0:00';
417
+ const mins = Math.floor(seconds / 60);
418
+ const secs = Math.floor(seconds % 60).toString().padStart(2, '0');
419
+ return `${mins}:${secs}`;
420
+ }
421
+
422
+ // Handle track end
423
+ function handleTrackEnd() {
424
+ if (isRepeatActive) {
425
+ audioElement.currentTime = 0;
426
+ audioElement.play();
427
+ } else {
428
+ playNext();
429
+ }
430
+ }
431
+
432
+ // Create visualization
433
+ function createVisualization() {
434
+ // Clear existing visualization
435
+ while(scene.children.length > 0) {
436
+ scene.remove(scene.children[0]);
437
+ }
438
+ visualizers = {
439
+ bars: [],
440
+ sphere: null,
441
+ particles: null
442
+ };
443
+
444
+ switch(visualizationType) {
445
+ case 'bars':
446
+ createBarsVisualization();
447
+ break;
448
+ case 'sphere':
449
+ createSphereVisualization();
450
+ break;
451
+ case 'particles':
452
+ createParticlesVisualization();
453
+ break;
454
+ }
455
+ }
456
+
457
+ // Update visualization
458
+ function updateVisualization(dataArray) {
459
+ switch(visualizationType) {
460
+ case 'bars':
461
+ updateBarsVisualization(dataArray);
462
+ break;
463
+ case 'sphere':
464
+ updateSphereVisualization(dataArray);
465
+ break;
466
+ case 'particles':
467
+ updateParticlesVisualization(dataArray);
468
+ break;
469
+ }
470
+ }
471
+
472
+ // Create playlist UI
473
+ function createPlaylist() {
474
+ const tracksList = document.querySelector('.tracks-list');
475
+ tracksList.innerHTML = '';
476
+
477
+ playlist.forEach((track, index) => {
478
+ const metadata = track.metadata || {};
479
+ const duration = metadata.duration ? formatTime(metadata.duration) : '0:00';
480
+ const artist = metadata.artist || 'Unknown Artist';
481
+ const title = metadata.title || track.name;
482
+
483
+ const trackElement = document.createElement('div');
484
+ trackElement.className = 'playlist-item';
485
+ trackElement.innerHTML = `
486
+ <div class="track-info">
487
+ <span class="track-number">${index + 1}</span>
488
+ <div class="track-content">
489
+ <div class="track-title">
490
+ ${title}
491
+ <span class="track-duration">${duration}</span>
492
+ </div>
493
+ <div class="track-metadata">
494
+ ${artist}
495
+ ${metadata.album ? ` • ${metadata.album}` : ''}
496
+ </div>
497
+ </div>
498
+ </div>
499
+ `;
500
+
501
+ trackElement.addEventListener('click', () => playTrack(index));
502
+ tracksList.appendChild(trackElement);
503
+ });
504
+ }
505
+
506
+ // Update now playing information
507
+ function updateNowPlayingInfo() {
508
+ if (currentTrackIndex >= 0 && currentTrackIndex < playlist.length) {
509
+ const track = playlist[currentTrackIndex];
510
+ const metadata = track.metadata || {};
511
+
512
+ const nowPlayingTitle = document.querySelector('.now-playing-title');
513
+ const nowPlayingArtist = document.querySelector('.now-playing-artist');
514
+
515
+ if (nowPlayingTitle) {
516
+ nowPlayingTitle.textContent = metadata.title || track.name;
517
+ }
518
+
519
+ if (nowPlayingArtist) {
520
+ let artistInfo = metadata.artist || 'Unknown Artist';
521
+ if (metadata.album) {
522
+ artistInfo += ` • ${metadata.album}`;
523
+ }
524
+ nowPlayingArtist.textContent = artistInfo;
525
+ }
526
+ }
527
+ }
528
+
529
+ // Play selected track
530
+ function playTrack(index) {
531
+ if (index < 0 || index >= playlist.length) return;
532
+
533
+ currentTrackIndex = index;
534
+ const track = playlist[index];
535
+
536
+ if (!audioElement) {
537
+ initAudio();
538
+ }
539
+
540
+ // Update playlist UI
541
+ document.querySelectorAll('.playlist-item').forEach((item, i) => {
542
+ item.classList.toggle('active', i === index);
543
+ });
544
+
545
+ // Enable controls
546
+ document.getElementById('play-pause').disabled = false;
547
+ document.querySelector('.previous-btn').disabled = false;
548
+ document.querySelector('.next-btn').disabled = false;
549
+
550
+ // Update audio source and play
551
+ audioElement.src = track.url;
552
+ audioElement.play()
553
+ .then(() => {
554
+ isPlaying = true;
555
+ document.getElementById('play-pause').innerHTML = '<i class="fas fa-pause"></i>';
556
+ updateNowPlayingInfo();
557
+
558
+ // Add event listeners for time updates if not already added
559
+ if (!audioElement.onended) {
560
+ audioElement.addEventListener('timeupdate', updateTimeDisplay);
561
+ audioElement.addEventListener('ended', handleTrackEnd);
562
+ }
563
+ })
564
+ .catch(error => {
565
+ console.error('Error playing track:', error);
566
+ showError('Error playing track');
567
+ });
568
+ }
569
+
570
+ // Add these functions after the existing initialization code
571
+ function setupToggleHandlers() {
572
+ const mainContent = document.querySelector('.main-content');
573
+ const uploadArea = document.querySelector('.upload-area');
574
+ const playlistContainer = document.querySelector('.playlist-container');
575
+ const uploadToggleBtn = document.querySelector('.upload-toggle-btn');
576
+ const playlistToggleBtn = document.querySelector('.playlist-toggle-btn');
577
+
578
+ // Show playlist by default
579
+ mainContent.classList.add('visible');
580
+ playlistContainer.classList.add('visible');
581
+ playlistToggleBtn.classList.add('active');
582
+
583
+ uploadToggleBtn?.addEventListener('click', () => {
584
+ const isVisible = uploadArea.classList.contains('visible');
585
+
586
+ // Hide playlist if it's visible
587
+ playlistContainer.classList.remove('visible');
588
+ playlistToggleBtn.classList.remove('active');
589
+
590
+ // Toggle upload area
591
+ uploadArea.classList.toggle('visible');
592
+ uploadToggleBtn.classList.toggle('active');
593
+
594
+ // Show/hide main content
595
+ mainContent.classList.toggle('visible', !isVisible || playlistContainer.classList.contains('visible'));
596
+ });
597
+
598
+ playlistToggleBtn?.addEventListener('click', () => {
599
+ const isVisible = playlistContainer.classList.contains('visible');
600
+
601
+ // Hide upload area if it's visible
602
+ uploadArea.classList.remove('visible');
603
+ uploadToggleBtn.classList.remove('active');
604
+
605
+ // Toggle playlist
606
+ playlistContainer.classList.toggle('visible');
607
+ playlistToggleBtn.classList.toggle('active');
608
+
609
+ // Show/hide main content
610
+ mainContent.classList.toggle('visible', !isVisible || uploadArea.classList.contains('visible'));
611
+ });
612
+ }
templates/index.html ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <head>
4
+ <title>3D Music Visualizer</title>
5
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
6
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
7
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
8
+ <script src="https://cdn.jsdelivr.net/npm/[email protected]/examples/js/controls/OrbitControls.js"></script>
9
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
10
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
11
+ </head>
12
+ <body>
13
+ <header class="app-header">
14
+ <div class="logo">
15
+ <i class="fas fa-music"></i>
16
+ <span>Soundscape</span>
17
+ </div>
18
+ <div class="header-controls">
19
+ <button class="header-btn upload-toggle-btn" title="Show upload">
20
+ <i class="fas fa-cloud-upload-alt"></i>
21
+ </button>
22
+ <button class="header-btn playlist-toggle-btn" title="Show playlist">
23
+ <i class="fas fa-list"></i>
24
+ </button>
25
+ <button class="header-btn theme-btn" title="Toggle theme">
26
+ <i class="fas fa-moon"></i>
27
+ </button>
28
+ </div>
29
+ </header>
30
+
31
+ <div class="main-content">
32
+ <div class="upload-area" id="upload-area">
33
+ <div class="upload-content">
34
+ <i class="fas fa-cloud-upload-alt"></i>
35
+ <div class="upload-text">
36
+ <h3>Drop your audio files here</h3>
37
+ <p>or click to browse</p>
38
+ <span class="file-types">Supports MP3, WAV, OGG, FLAC</span>
39
+ </div>
40
+ <div id="file-name"></div>
41
+ </div>
42
+ <input type="file" id="audio-upload" accept=".mp3,.wav,.ogg,.flac" multiple>
43
+ </div>
44
+
45
+ <div class="playlist-container">
46
+ <div class="playlist-header">
47
+ <h3>Playlist</h3>
48
+ <div class="playlist-controls">
49
+ <button class="playlist-btn shuffle-btn" title="Shuffle">
50
+ <i class="fas fa-random"></i>
51
+ </button>
52
+ <button class="playlist-btn repeat-btn" title="Repeat">
53
+ <i class="fas fa-redo"></i>
54
+ </button>
55
+ </div>
56
+ </div>
57
+ <div class="tracks-list">
58
+ <!-- Tracks will be dynamically added here -->
59
+ </div>
60
+ </div>
61
+ </div>
62
+
63
+ <div id="controls" class="controls-container">
64
+ <div class="now-playing-info">
65
+ <div class="now-playing-text">
66
+ <div class="now-playing-title">No track selected</div>
67
+ <div class="now-playing-artist">Upload some music to begin</div>
68
+ </div>
69
+ <div class="time-display">
70
+ <span class="current-time">0:00</span> /
71
+ <span class="total-time">0:00</span>
72
+ </div>
73
+ </div>
74
+
75
+ <div class="progress-bar">
76
+ <div class="progress"></div>
77
+ <input type="range" class="seek-slider" min="0" max="100" value="0">
78
+ </div>
79
+
80
+ <div class="music-controls">
81
+ <button class="control-btn previous-btn" disabled>
82
+ <i class="fas fa-backward"></i>
83
+ </button>
84
+ <button class="control-btn play-pause-btn" id="play-pause" disabled>
85
+ <i class="fas fa-play"></i>
86
+ </button>
87
+ <button class="control-btn next-btn" disabled>
88
+ <i class="fas fa-forward"></i>
89
+ </button>
90
+
91
+ <div class="volume-control">
92
+ <button class="volume-btn">
93
+ <i class="fas fa-volume-up"></i>
94
+ </button>
95
+ <div class="volume-slider-container">
96
+ <div class="volume-progress"></div>
97
+ <input type="range" id="volume" min="0" max="1" step="0.1" value="0.5">
98
+ </div>
99
+ </div>
100
+
101
+ <select id="visualization-type">
102
+ <option value="bars">Circular Bars</option>
103
+ <option value="sphere">Sphere</option>
104
+ <option value="particles">Particles</option>
105
+ </select>
106
+ </div>
107
+ </div>
108
+
109
+ <div class="error-toast"></div>
110
+ <div class="tooltip"></div>
111
+
112
+ <script src="{{ url_for('static', filename='js/main.js') }}"></script>
113
+ </body>
114
+ </html>