初始提交
This commit is contained in:
parent
a93f7e4ff5
commit
38b1c613b5
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
77
index.html
Normal file
77
index.html
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>音乐播放器 - 波形图可视化</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<div class="audio-player">
|
||||||
|
<h1>音乐播放器</h1>
|
||||||
|
|
||||||
|
<!-- 文件选择 -->
|
||||||
|
<div class="file-selector">
|
||||||
|
<input type="file" id="audioFile" accept="audio/*" />
|
||||||
|
<label for="audioFile" class="file-label">选择音乐文件</label>
|
||||||
|
<input type="file" id="audioFolder" webkitdirectory directory multiple accept="audio/*" style="display: none;" />
|
||||||
|
<label for="audioFolder" class="file-label folder-label">选择音乐文件夹</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 当前播放信息 -->
|
||||||
|
<div class="track-info">
|
||||||
|
<p id="trackName">请选择音乐文件</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 波形图显示 -->
|
||||||
|
<div class="waveform-container">
|
||||||
|
<canvas id="waveform" width="800" height="300"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 音频元素(隐藏,只用于音频播放) -->
|
||||||
|
<audio id="audio" style="display: none;"></audio>
|
||||||
|
|
||||||
|
<!-- 播放控制和进度条 -->
|
||||||
|
<div class="playback-progress-container">
|
||||||
|
<button id="prevBtn" class="control-btn nav-btn" title="上一首 (P)">⏮️</button>
|
||||||
|
<button id="playPauseBtn" class="control-btn play-pause-btn">▶️</button>
|
||||||
|
<button id="nextBtn" class="control-btn nav-btn" title="下一首 (N)">⏭️</button>
|
||||||
|
<div class="progress-container">
|
||||||
|
<div class="progress-bar" id="progressBar">
|
||||||
|
<div id="progress" class="progress"></div>
|
||||||
|
<div class="progress-handle" id="progressHandle"></div>
|
||||||
|
</div>
|
||||||
|
<div class="time-display">
|
||||||
|
<span id="currentTime">0:00</span> / <span id="duration">0:00</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button id="stopBtn" class="control-btn stop-btn">⏹️</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部控制栏 -->
|
||||||
|
<div class="bottom-controls">
|
||||||
|
<!-- 播放模式控制 -->
|
||||||
|
<div class="playback-mode-control">
|
||||||
|
<button id="playbackModeBtn" class="control-btn" title="切换播放模式">🔁</button>
|
||||||
|
<span id="playbackModeText">列表循环</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 可视化控制 -->
|
||||||
|
<div class="visualization-control">
|
||||||
|
<button id="visualizationBtn" class="control-btn">🎨 切换可视化</button>
|
||||||
|
<span id="visualizationMode">条形图</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 音量控制 -->
|
||||||
|
<div class="volume-control">
|
||||||
|
<button id="muteBtn" class="control-btn volume-btn">🔊</button>
|
||||||
|
<input type="range" id="volumeSlider" min="0" max="100" value="50" />
|
||||||
|
<span id="volumeValue">50%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1105
package-lock.json
generated
Normal file
1105
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
14
package.json
Normal file
14
package.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "audio-player",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vite": "^7.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
9
src/counter.js
Normal file
9
src/counter.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export function setupCounter(element) {
|
||||||
|
let counter = 0
|
||||||
|
const setCounter = (count) => {
|
||||||
|
counter = count
|
||||||
|
element.innerHTML = `count is ${counter}`
|
||||||
|
}
|
||||||
|
element.addEventListener('click', () => setCounter(counter + 1))
|
||||||
|
setCounter(0)
|
||||||
|
}
|
||||||
1
src/javascript.svg
Normal file
1
src/javascript.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#F7DF1E" d="M0 0h256v256H0V0Z"></path><path d="m67.312 213.932l19.59-11.856c3.78 6.701 7.218 12.371 15.465 12.371c7.905 0 12.89-3.092 12.89-15.12v-81.798h24.057v82.138c0 24.917-14.606 36.259-35.916 36.259c-19.245 0-30.416-9.967-36.087-21.996m85.07-2.576l19.588-11.341c5.157 8.421 11.859 14.607 23.715 14.607c9.969 0 16.325-4.984 16.325-11.858c0-8.248-6.53-11.17-17.528-15.98l-6.013-2.58c-17.357-7.387-28.87-16.667-28.87-36.257c0-18.044 13.747-31.792 35.228-31.792c15.294 0 26.292 5.328 34.196 19.247l-18.732 12.03c-4.125-7.389-8.591-10.31-15.465-10.31c-7.046 0-11.514 4.468-11.514 10.31c0 7.217 4.468 10.14 14.778 14.608l6.014 2.577c20.45 8.765 31.963 17.7 31.963 37.804c0 21.654-17.012 33.51-39.867 33.51c-22.339 0-36.774-10.654-43.819-24.574"></path></svg>
|
||||||
|
After Width: | Height: | Size: 995 B |
773
src/main.js
Normal file
773
src/main.js
Normal file
@ -0,0 +1,773 @@
|
|||||||
|
import './style.css'
|
||||||
|
import WaveformVisualizer from './waveformVisualizer.js'
|
||||||
|
|
||||||
|
// 音频播放器类
|
||||||
|
class AudioPlayer {
|
||||||
|
constructor() {
|
||||||
|
this.audio = document.getElementById('audio');
|
||||||
|
this.canvas = document.getElementById('waveform');
|
||||||
|
this.ctx = this.canvas.getContext('2d');
|
||||||
|
this.audioContext = null;
|
||||||
|
this.analyser = null;
|
||||||
|
this.source = null;
|
||||||
|
this.dataArray = null;
|
||||||
|
this.animationId = null;
|
||||||
|
this.waveformVisualizer = null;
|
||||||
|
|
||||||
|
// 播放列表相关
|
||||||
|
this.playlist = [];
|
||||||
|
this.currentTrackIndex = 0;
|
||||||
|
this.isPlayingFolder = false;
|
||||||
|
this.playbackMode = 'loop'; // 默认列表循环模式:'single', 'loop', 'random'
|
||||||
|
|
||||||
|
// Web Audio API警告标志
|
||||||
|
this.webAudioApiWarningShown = false;
|
||||||
|
|
||||||
|
// 自动播放状态跟踪
|
||||||
|
this.shouldAutoPlay = false;
|
||||||
|
|
||||||
|
this.initializeElements();
|
||||||
|
this.setupEventListeners();
|
||||||
|
this.setupCanvas();
|
||||||
|
this.setupKeyboardShortcuts();
|
||||||
|
this.initializePlayerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeElements() {
|
||||||
|
// 获取所有DOM元素
|
||||||
|
this.fileInput = document.getElementById('audioFile');
|
||||||
|
this.folderInput = document.getElementById('audioFolder');
|
||||||
|
this.playPauseBtn = document.getElementById('playPauseBtn');
|
||||||
|
this.prevBtn = document.getElementById('prevBtn');
|
||||||
|
this.nextBtn = document.getElementById('nextBtn');
|
||||||
|
this.stopBtn = document.getElementById('stopBtn');
|
||||||
|
this.progress = document.getElementById('progress');
|
||||||
|
this.currentTime = document.getElementById('currentTime');
|
||||||
|
this.duration = document.getElementById('duration');
|
||||||
|
this.volumeSlider = document.getElementById('volumeSlider');
|
||||||
|
this.volumeValue = document.getElementById('volumeValue');
|
||||||
|
this.trackName = document.getElementById('trackName');
|
||||||
|
this.muteBtn = document.getElementById('muteBtn');
|
||||||
|
this.visualizationBtn = document.getElementById('visualizationBtn');
|
||||||
|
this.visualizationMode = document.getElementById('visualizationMode');
|
||||||
|
this.playbackModeBtn = document.getElementById('playbackModeBtn');
|
||||||
|
this.playbackModeText = document.getElementById('playbackModeText');
|
||||||
|
this.progressBar = document.getElementById('progressBar');
|
||||||
|
this.progressHandle = document.getElementById('progressHandle');
|
||||||
|
|
||||||
|
// 初始化音频音量和状态
|
||||||
|
this.audio.volume = 0.5; // 设置默认音量为50%
|
||||||
|
this.previousVolume = 0.5; // 用于静音前的音量记忆
|
||||||
|
this.isMuted = false;
|
||||||
|
this.isDraggingProgress = false; // 进度条拖动状态
|
||||||
|
}
|
||||||
|
|
||||||
|
setupCanvas() {
|
||||||
|
// 设置canvas尺寸
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
this.canvas.width = rect.width * window.devicePixelRatio;
|
||||||
|
this.canvas.height = rect.height * window.devicePixelRatio;
|
||||||
|
this.ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
|
||||||
|
this.canvas.style.width = rect.width + 'px';
|
||||||
|
this.canvas.style.height = rect.height + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEventListeners() {
|
||||||
|
// 文件选择
|
||||||
|
this.fileInput.addEventListener('change', (e) => this.handleFileSelect(e));
|
||||||
|
this.folderInput.addEventListener('change', (e) => this.handleFolderSelect(e));
|
||||||
|
|
||||||
|
// 音频结束事件(用于自动播放下一首)
|
||||||
|
this.audio.addEventListener('ended', () => this.handleTrackEnd());
|
||||||
|
|
||||||
|
// 播放控制
|
||||||
|
this.playPauseBtn.addEventListener('click', () => this.togglePlayPause());
|
||||||
|
this.prevBtn.addEventListener('click', () => this.previousTrack());
|
||||||
|
this.nextBtn.addEventListener('click', () => this.nextTrack());
|
||||||
|
this.stopBtn.addEventListener('click', () => this.stop());
|
||||||
|
|
||||||
|
// 播放模式切换
|
||||||
|
this.playbackModeBtn.addEventListener('click', () => this.togglePlaybackMode());
|
||||||
|
|
||||||
|
// 音频事件
|
||||||
|
this.audio.addEventListener('loadedmetadata', () => this.updateDuration());
|
||||||
|
this.audio.addEventListener('timeupdate', () => this.updateProgress());
|
||||||
|
this.audio.addEventListener('ended', () => this.stop());
|
||||||
|
this.audio.addEventListener('volumechange', () => this.syncVolumeSlider());
|
||||||
|
|
||||||
|
// 音量控制 - 双向联动
|
||||||
|
this.volumeSlider.addEventListener('input', (e) => this.updateVolume(e));
|
||||||
|
|
||||||
|
// 静音按钮
|
||||||
|
this.muteBtn.addEventListener('click', () => this.toggleMute());
|
||||||
|
|
||||||
|
// Canvas点击切换可视化模式(不再用于跳转进度)
|
||||||
|
this.canvas.addEventListener('click', () => this.switchVisualization());
|
||||||
|
|
||||||
|
// 可视化模式切换按钮
|
||||||
|
this.visualizationBtn.addEventListener('click', () => this.switchVisualization());
|
||||||
|
|
||||||
|
// 进度条拖动功能
|
||||||
|
this.setupProgressDrag();
|
||||||
|
}
|
||||||
|
|
||||||
|
setupProgressDrag() {
|
||||||
|
// 进度条拖动功能
|
||||||
|
let isDragging = false;
|
||||||
|
|
||||||
|
const handleProgressDrag = (e) => {
|
||||||
|
if (!this.audio.duration) return;
|
||||||
|
|
||||||
|
const rect = this.progressBar.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const percentage = Math.max(0, Math.min(1, x / rect.width));
|
||||||
|
const newTime = percentage * this.audio.duration;
|
||||||
|
|
||||||
|
this.audio.currentTime = newTime;
|
||||||
|
this.updateProgressBar(percentage);
|
||||||
|
};
|
||||||
|
|
||||||
|
const startDrag = (e) => {
|
||||||
|
isDragging = true;
|
||||||
|
this.isDraggingProgress = true;
|
||||||
|
document.addEventListener('mousemove', handleProgressDrag);
|
||||||
|
document.addEventListener('mouseup', stopDrag);
|
||||||
|
handleProgressDrag(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopDrag = () => {
|
||||||
|
isDragging = false;
|
||||||
|
this.isDraggingProgress = false;
|
||||||
|
document.removeEventListener('mousemove', handleProgressDrag);
|
||||||
|
document.removeEventListener('mouseup', stopDrag);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 进度条点击
|
||||||
|
this.progressBar.addEventListener('click', handleProgressDrag);
|
||||||
|
|
||||||
|
// 拖动手柄
|
||||||
|
this.progressHandle.addEventListener('mousedown', startDrag);
|
||||||
|
|
||||||
|
// 防止拖动时选中文本
|
||||||
|
this.progressHandle.addEventListener('selectstart', (e) => e.preventDefault());
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProgressBar(percentage) {
|
||||||
|
// 更新进度条显示(不依赖于timeupdate事件)
|
||||||
|
this.progress.style.width = (percentage * 100) + '%';
|
||||||
|
|
||||||
|
// 更新手柄位置
|
||||||
|
if (this.progressHandle && this.progressBar) {
|
||||||
|
const handleX = percentage * this.progressBar.offsetWidth - 8; // 8是手柄宽度的一半
|
||||||
|
this.progressHandle.style.left = Math.max(-8, Math.min(this.progressBar.offsetWidth - 8, handleX)) + 'px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFileSelect(event) {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
// 单个文件播放模式
|
||||||
|
this.isPlayingFolder = false;
|
||||||
|
this.playlist = [];
|
||||||
|
this.currentTrackIndex = 0;
|
||||||
|
|
||||||
|
// 清理旧的URL
|
||||||
|
if (this.audio.src) {
|
||||||
|
URL.revokeObjectURL(this.audio.src);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
this.audio.src = url;
|
||||||
|
this.trackName.textContent = file.name;
|
||||||
|
|
||||||
|
// 确保音量设置保持一致
|
||||||
|
const currentVolume = this.volumeSlider.value / 100;
|
||||||
|
this.audio.volume = currentVolume;
|
||||||
|
|
||||||
|
// 设置音频上下文(如果尚未初始化)
|
||||||
|
this.setupAudioContext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFolderSelect(event) {
|
||||||
|
const files = Array.from(event.target.files);
|
||||||
|
if (files.length > 0) {
|
||||||
|
// 筛选音频文件 - 支持更多格式
|
||||||
|
const audioExtensions = ['mp3', 'wav', 'ogg', 'm4a', 'flac', 'aac', 'wma', 'opus', 'webm'];
|
||||||
|
const audioFiles = files.filter(file => {
|
||||||
|
const extension = file.name.split('.').pop().toLowerCase();
|
||||||
|
return file.type.startsWith('audio/') || audioExtensions.includes(extension);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (audioFiles.length > 0) {
|
||||||
|
// 按文件名排序
|
||||||
|
audioFiles.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
this.playlist = audioFiles;
|
||||||
|
this.currentTrackIndex = 0;
|
||||||
|
this.isPlayingFolder = true;
|
||||||
|
|
||||||
|
// 确保播放模式显示正确(默认列表循环)
|
||||||
|
this.playbackMode = 'loop';
|
||||||
|
this.updatePlaybackModeDisplay();
|
||||||
|
|
||||||
|
// 确保音频上下文已初始化
|
||||||
|
this.setupAudioContext();
|
||||||
|
|
||||||
|
// 播放第一首
|
||||||
|
this.loadTrack(0);
|
||||||
|
console.log(`已加载文件夹,共${audioFiles.length}首音乐`);
|
||||||
|
|
||||||
|
// 显示成功提示
|
||||||
|
this.showFolderLoadedFeedback(audioFiles.length);
|
||||||
|
} else {
|
||||||
|
alert('文件夹中没有找到音频文件');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showFolderLoadedFeedback(count) {
|
||||||
|
const feedback = document.createElement('div');
|
||||||
|
feedback.textContent = `已加载 ${count} 首音乐`;
|
||||||
|
feedback.className = 'visualization-feedback';
|
||||||
|
feedback.style.background = 'rgba(76, 175, 80, 0.9)';
|
||||||
|
|
||||||
|
document.body.appendChild(feedback);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (feedback.parentNode) {
|
||||||
|
feedback.parentNode.removeChild(feedback);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTrack(index, shouldAutoPlay = true) {
|
||||||
|
if (index < 0 || index >= this.playlist.length) return;
|
||||||
|
|
||||||
|
this.currentTrackIndex = index;
|
||||||
|
const file = this.playlist[index];
|
||||||
|
|
||||||
|
// 停止当前播放和可视化
|
||||||
|
this.stopVisualization();
|
||||||
|
|
||||||
|
// 清理旧的URL
|
||||||
|
if (this.audio.src) {
|
||||||
|
URL.revokeObjectURL(this.audio.src);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(file);
|
||||||
|
this.audio.src = url;
|
||||||
|
this.trackName.textContent = `${index + 1}/${this.playlist.length}: ${file.name}`;
|
||||||
|
|
||||||
|
// 确保音量设置保持一致
|
||||||
|
const currentVolume = this.volumeSlider.value / 100;
|
||||||
|
this.audio.volume = currentVolume;
|
||||||
|
|
||||||
|
// 设置音频上下文(如果尚未初始化)
|
||||||
|
this.setupAudioContext();
|
||||||
|
|
||||||
|
this.updateNavigationButtons();
|
||||||
|
|
||||||
|
// 设置自动播放状态
|
||||||
|
this.shouldAutoPlay = shouldAutoPlay;
|
||||||
|
|
||||||
|
// 等待音频数据加载完成后播放
|
||||||
|
const tryAutoPlay = () => {
|
||||||
|
if (this.audio.readyState >= 2 && (!this.audio.paused || this.shouldAutoPlay)) {
|
||||||
|
this.play();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this.audio.addEventListener('loadedmetadata', tryAutoPlay, { once: true });
|
||||||
|
this.audio.addEventListener('canplay', tryAutoPlay, { once: true });
|
||||||
|
this.audio.addEventListener('loadeddata', tryAutoPlay, { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleTrackEnd() {
|
||||||
|
if (!this.isPlayingFolder || this.playlist.length === 0) return;
|
||||||
|
|
||||||
|
let nextIndex;
|
||||||
|
switch (this.playbackMode) {
|
||||||
|
case 'single':
|
||||||
|
// 单曲循环
|
||||||
|
nextIndex = this.currentTrackIndex;
|
||||||
|
break;
|
||||||
|
case 'random':
|
||||||
|
// 随机播放
|
||||||
|
nextIndex = Math.floor(Math.random() * this.playlist.length);
|
||||||
|
break;
|
||||||
|
case 'loop':
|
||||||
|
default:
|
||||||
|
// 列表循环
|
||||||
|
nextIndex = (this.currentTrackIndex + 1) % this.playlist.length;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动播放下一首
|
||||||
|
this.loadTrack(nextIndex, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTrack() {
|
||||||
|
if (!this.isPlayingFolder || this.playlist.length === 0) return;
|
||||||
|
|
||||||
|
const nextIndex = (this.currentTrackIndex + 1) % this.playlist.length;
|
||||||
|
this.loadTrack(nextIndex, true); // 自动播放
|
||||||
|
}
|
||||||
|
|
||||||
|
previousTrack() {
|
||||||
|
if (!this.isPlayingFolder || this.playlist.length === 0) return;
|
||||||
|
|
||||||
|
const prevIndex = this.currentTrackIndex === 0 ? this.playlist.length - 1 : this.currentTrackIndex - 1;
|
||||||
|
this.loadTrack(prevIndex, true); // 自动播放
|
||||||
|
}
|
||||||
|
|
||||||
|
setupAudioContext() {
|
||||||
|
try {
|
||||||
|
// 首次创建音频上下文
|
||||||
|
if (!this.audioContext) {
|
||||||
|
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
this.analyser = this.audioContext.createAnalyser();
|
||||||
|
this.analyser.fftSize = 2048;
|
||||||
|
|
||||||
|
const bufferLength = this.analyser.frequencyBinCount;
|
||||||
|
this.dataArray = new Uint8Array(bufferLength);
|
||||||
|
|
||||||
|
// 初始化高级可视化器
|
||||||
|
this.waveformVisualizer = new WaveformVisualizer(this.canvas, this.audioContext, this.analyser);
|
||||||
|
console.log('音频上下文和可视化器初始化成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 连接音频源(只在首次或需要重新连接时)
|
||||||
|
if (this.audioContext && this.analyser && !this.source) {
|
||||||
|
this.source = this.audioContext.createMediaElementSource(this.audio);
|
||||||
|
this.source.connect(this.analyser);
|
||||||
|
this.analyser.connect(this.audioContext.destination);
|
||||||
|
console.log('音频源连接成功');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('音频上下文初始化失败:', error);
|
||||||
|
if (!this.webAudioApiWarningShown) {
|
||||||
|
alert('您的浏览器不支持Web Audio API,部分功能可能无法正常使用');
|
||||||
|
this.webAudioApiWarningShown = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
play() {
|
||||||
|
if (this.audio.src) {
|
||||||
|
// 确保音频上下文处于运行状态
|
||||||
|
if (this.audioContext && this.audioContext.state === 'suspended') {
|
||||||
|
this.audioContext.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.audio.play().then(() => {
|
||||||
|
this.startVisualization();
|
||||||
|
this.updatePlayPauseButton(true);
|
||||||
|
document.querySelector('.audio-player').classList.add('playing');
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('播放失败:', error);
|
||||||
|
alert('播放失败,请检查音频文件');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pause() {
|
||||||
|
this.audio.pause();
|
||||||
|
this.stopVisualization();
|
||||||
|
this.updatePlayPauseButton(false);
|
||||||
|
document.querySelector('.audio-player').classList.remove('playing');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除旧的togglePlayPause方法,因为已经移到了上面
|
||||||
|
|
||||||
|
updatePlayPauseButton(isPlaying) {
|
||||||
|
if (this.playPauseBtn) {
|
||||||
|
this.playPauseBtn.textContent = isPlaying ? '⏸️' : '▶️';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.audio.pause();
|
||||||
|
this.audio.currentTime = 0;
|
||||||
|
this.stopVisualization();
|
||||||
|
this.clearCanvas();
|
||||||
|
this.updatePlayPauseButton(false);
|
||||||
|
document.querySelector('.audio-player').classList.remove('playing');
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDuration() {
|
||||||
|
const duration = this.audio.duration;
|
||||||
|
this.duration.textContent = this.formatTime(duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProgress() {
|
||||||
|
// 只在不拖动时更新进度条
|
||||||
|
if (this.isDraggingProgress) return;
|
||||||
|
const currentTime = this.audio.currentTime;
|
||||||
|
const duration = this.audio.duration;
|
||||||
|
const progressPercent = (currentTime / duration) * 100;
|
||||||
|
this.progress.style.width = progressPercent + '%';
|
||||||
|
this.currentTime.textContent = this.formatTime(currentTime);
|
||||||
|
// 更新手柄位置
|
||||||
|
if (this.progressHandle && this.progressBar) {
|
||||||
|
const handleX = (currentTime / duration) * this.progressBar.offsetWidth - 8;
|
||||||
|
this.progressHandle.style.left = Math.max(-8, Math.min(this.progressBar.offsetWidth - 8, handleX)) + 'px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateVolume(event) {
|
||||||
|
const volume = event.target.value / 100;
|
||||||
|
this.audio.volume = volume;
|
||||||
|
this.volumeValue.textContent = event.target.value + '%';
|
||||||
|
|
||||||
|
// 更新静音状态
|
||||||
|
if (volume > 0 && this.isMuted) {
|
||||||
|
this.isMuted = false;
|
||||||
|
} else if (volume === 0 && !this.isMuted) {
|
||||||
|
this.isMuted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果音量大于0且不是静音状态,保存当前音量
|
||||||
|
if (volume > 0 && !this.isMuted) {
|
||||||
|
this.previousVolume = volume;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateVolumeIcon(Math.round(volume * 100));
|
||||||
|
console.log('音量设置为:', volume, '静音状态:', this.isMuted);
|
||||||
|
}
|
||||||
|
|
||||||
|
syncVolumeSlider() {
|
||||||
|
// 同步滑块位置与音频实际音量(处理音频元素自身的音量变化)
|
||||||
|
const currentVolume = Math.round(this.audio.volume * 100);
|
||||||
|
this.volumeSlider.value = currentVolume;
|
||||||
|
this.volumeValue.textContent = currentVolume + '%';
|
||||||
|
this.updateVolumeIcon(currentVolume);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateVolumeIcon(volume) {
|
||||||
|
if (this.muteBtn) {
|
||||||
|
if (volume === 0 || this.isMuted) {
|
||||||
|
this.muteBtn.textContent = '🔇';
|
||||||
|
this.muteBtn.classList.add('muted');
|
||||||
|
} else if (volume < 30) {
|
||||||
|
this.muteBtn.textContent = '🔈';
|
||||||
|
this.muteBtn.classList.remove('muted');
|
||||||
|
} else if (volume < 70) {
|
||||||
|
this.muteBtn.textContent = '🔉';
|
||||||
|
this.muteBtn.classList.remove('muted');
|
||||||
|
} else {
|
||||||
|
this.muteBtn.textContent = '🔊';
|
||||||
|
this.muteBtn.classList.remove('muted');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleMute() {
|
||||||
|
if (this.isMuted) {
|
||||||
|
// 取消静音,恢复到之前的音量
|
||||||
|
this.audio.volume = this.previousVolume;
|
||||||
|
this.volumeSlider.value = Math.round(this.previousVolume * 100);
|
||||||
|
this.volumeValue.textContent = Math.round(this.previousVolume * 100) + '%';
|
||||||
|
this.isMuted = false;
|
||||||
|
} else {
|
||||||
|
// 静音,保存当前音量
|
||||||
|
this.previousVolume = this.audio.volume;
|
||||||
|
this.audio.volume = 0;
|
||||||
|
this.volumeSlider.value = 0;
|
||||||
|
this.volumeValue.textContent = '0%';
|
||||||
|
this.isMuted = true;
|
||||||
|
}
|
||||||
|
this.updateVolumeIcon(this.audio.volume);
|
||||||
|
console.log(this.isMuted ? '已静音' : '取消静音', '音量:', this.audio.volume);
|
||||||
|
}
|
||||||
|
|
||||||
|
formatTime(seconds) {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
startVisualization() {
|
||||||
|
if (!this.waveformVisualizer) {
|
||||||
|
console.warn('可视化器未初始化,使用备用方案');
|
||||||
|
this.startBasicVisualization();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
this.animationId = requestAnimationFrame(draw);
|
||||||
|
this.waveformVisualizer.draw();
|
||||||
|
};
|
||||||
|
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
startBasicVisualization() {
|
||||||
|
// 备用基础可视化方案
|
||||||
|
if (!this.analyser) return;
|
||||||
|
|
||||||
|
const draw = () => {
|
||||||
|
this.animationId = requestAnimationFrame(draw);
|
||||||
|
|
||||||
|
this.analyser.getByteFrequencyData(this.dataArray);
|
||||||
|
|
||||||
|
this.ctx.fillStyle = 'rgb(0, 0, 0)';
|
||||||
|
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
|
const barWidth = (this.canvas.width / this.dataArray.length) * 2.5;
|
||||||
|
let barHeight;
|
||||||
|
let x = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < this.dataArray.length; i++) {
|
||||||
|
barHeight = (this.dataArray[i] / 255) * this.canvas.height;
|
||||||
|
|
||||||
|
const r = barHeight + (25 * (i / this.dataArray.length));
|
||||||
|
const g = 250 * (i / this.dataArray.length);
|
||||||
|
const b = 50;
|
||||||
|
|
||||||
|
this.ctx.fillStyle = `rgb(${r},${g},${b})`;
|
||||||
|
this.ctx.fillRect(x, this.canvas.height - barHeight, barWidth, barHeight);
|
||||||
|
|
||||||
|
x += barWidth + 1;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
stopVisualization() {
|
||||||
|
if (this.animationId) {
|
||||||
|
cancelAnimationFrame(this.animationId);
|
||||||
|
this.animationId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCanvas() {
|
||||||
|
if (this.waveformVisualizer) {
|
||||||
|
this.ctx.fillStyle = 'rgb(0, 0, 0)';
|
||||||
|
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switchVisualization() {
|
||||||
|
if (this.waveformVisualizer) {
|
||||||
|
this.waveformVisualizer.nextVisualization();
|
||||||
|
const currentMode = this.waveformVisualizer.getCurrentVisualization();
|
||||||
|
const modeNames = {
|
||||||
|
'bars': '条形图',
|
||||||
|
'wave': '波形图',
|
||||||
|
'circular': '圆形图'
|
||||||
|
};
|
||||||
|
if (this.visualizationMode) {
|
||||||
|
this.visualizationMode.textContent = modeNames[currentMode] || '条形图';
|
||||||
|
console.log('切换到可视化模式:', currentMode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加视觉反馈
|
||||||
|
this.showVisualizationFeedback(currentMode);
|
||||||
|
} else {
|
||||||
|
console.warn('可视化器未初始化');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showVisualizationFeedback(mode) {
|
||||||
|
// 创建切换反馈效果
|
||||||
|
const feedback = document.createElement('div');
|
||||||
|
feedback.textContent = this.getModeDisplayName(mode);
|
||||||
|
feedback.className = 'visualization-feedback';
|
||||||
|
|
||||||
|
document.body.appendChild(feedback);
|
||||||
|
|
||||||
|
// 1.5秒后移除反馈元素
|
||||||
|
setTimeout(() => {
|
||||||
|
if (feedback.parentNode) {
|
||||||
|
feedback.parentNode.removeChild(feedback);
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
getModeDisplayName(mode) {
|
||||||
|
const modeNames = {
|
||||||
|
'bars': '🔊 条形图模式',
|
||||||
|
'wave': '〰️ 波形图模式',
|
||||||
|
'circular': '⭕ 圆形图模式'
|
||||||
|
};
|
||||||
|
return modeNames[mode] || '条形图模式';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 键盘快捷键相关方法
|
||||||
|
setupKeyboardShortcuts() {
|
||||||
|
// 键盘快捷键支持
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
// 防止在输入框中触发快捷键
|
||||||
|
if (e.target.tagName === 'INPUT') return;
|
||||||
|
|
||||||
|
switch (e.key.toLowerCase()) {
|
||||||
|
case ' ':
|
||||||
|
e.preventDefault();
|
||||||
|
this.togglePlayPause();
|
||||||
|
break;
|
||||||
|
case 'm':
|
||||||
|
this.toggleMute();
|
||||||
|
break;
|
||||||
|
case 'arrowup':
|
||||||
|
e.preventDefault();
|
||||||
|
this.adjustVolume(5);
|
||||||
|
break;
|
||||||
|
case 'arrowdown':
|
||||||
|
e.preventDefault();
|
||||||
|
this.adjustVolume(-5);
|
||||||
|
break;
|
||||||
|
case 'arrowleft':
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.ctrlKey && this.isPlayingFolder) {
|
||||||
|
this.previousTrack();
|
||||||
|
} else {
|
||||||
|
this.skipTime(-5);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'arrowright':
|
||||||
|
e.preventDefault();
|
||||||
|
if (e.ctrlKey && this.isPlayingFolder) {
|
||||||
|
this.nextTrack();
|
||||||
|
} else {
|
||||||
|
this.skipTime(5);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'n':
|
||||||
|
if (this.isPlayingFolder) {
|
||||||
|
this.nextTrack();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'p':
|
||||||
|
if (this.isPlayingFolder) {
|
||||||
|
this.previousTrack();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
initializePlayerState() {
|
||||||
|
// 初始化播放器状态
|
||||||
|
this.updatePlayPauseButton(false);
|
||||||
|
document.querySelector('.audio-player').classList.remove('playing');
|
||||||
|
|
||||||
|
// 初始化播放模式显示(直接设置为列表循环)
|
||||||
|
this.playbackMode = 'loop';
|
||||||
|
this.updatePlaybackModeDisplay();
|
||||||
|
|
||||||
|
// 初始状态下禁用导航按钮
|
||||||
|
this.updateNavigationButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateNavigationButtons() {
|
||||||
|
const hasPlaylist = this.isPlayingFolder && this.playlist.length > 0;
|
||||||
|
this.prevBtn.disabled = !hasPlaylist;
|
||||||
|
this.nextBtn.disabled = !hasPlaylist;
|
||||||
|
this.playbackModeBtn.disabled = !hasPlaylist;
|
||||||
|
|
||||||
|
if (!hasPlaylist) {
|
||||||
|
this.prevBtn.style.opacity = '0.5';
|
||||||
|
this.nextBtn.style.opacity = '0.5';
|
||||||
|
this.playbackModeBtn.style.opacity = '0.5';
|
||||||
|
} else {
|
||||||
|
this.prevBtn.style.opacity = '1';
|
||||||
|
this.nextBtn.style.opacity = '1';
|
||||||
|
this.playbackModeBtn.style.opacity = '1';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePlayPause() {
|
||||||
|
if (this.audio.paused) {
|
||||||
|
this.play();
|
||||||
|
} else {
|
||||||
|
this.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
adjustVolume(change) {
|
||||||
|
const currentVolume = Math.round(this.audio.volume * 100);
|
||||||
|
const newVolume = Math.max(0, Math.min(100, currentVolume + change));
|
||||||
|
|
||||||
|
this.volumeSlider.value = newVolume;
|
||||||
|
this.audio.volume = newVolume / 100;
|
||||||
|
this.volumeValue.textContent = newVolume + '%';
|
||||||
|
|
||||||
|
// 更新静音状态
|
||||||
|
if (newVolume > 0 && this.isMuted) {
|
||||||
|
this.isMuted = false;
|
||||||
|
} else if (newVolume === 0 && !this.isMuted) {
|
||||||
|
this.isMuted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newVolume > 0 && !this.isMuted) {
|
||||||
|
this.previousVolume = newVolume / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateVolumeIcon(newVolume);
|
||||||
|
console.log('键盘调节音量:', newVolume);
|
||||||
|
}
|
||||||
|
|
||||||
|
skipTime(seconds) {
|
||||||
|
if (this.audio.duration) {
|
||||||
|
const newTime = Math.max(0, Math.min(this.audio.duration, this.audio.currentTime + seconds));
|
||||||
|
this.audio.currentTime = newTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePlaybackModeDisplay() {
|
||||||
|
const modeIcons = {
|
||||||
|
'loop': '🔁',
|
||||||
|
'single': '🔂',
|
||||||
|
'random': '🔀'
|
||||||
|
};
|
||||||
|
|
||||||
|
const modeTexts = {
|
||||||
|
'loop': '列表循环',
|
||||||
|
'single': '单曲循环',
|
||||||
|
'random': '随机播放'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (this.playbackModeBtn && this.playbackModeText) {
|
||||||
|
this.playbackModeBtn.textContent = modeIcons[this.playbackMode];
|
||||||
|
this.playbackModeText.textContent = modeTexts[this.playbackMode];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePlaybackMode() {
|
||||||
|
const modes = ['loop', 'single', 'random'];
|
||||||
|
const currentIndex = modes.indexOf(this.playbackMode);
|
||||||
|
this.playbackMode = modes[(currentIndex + 1) % modes.length];
|
||||||
|
|
||||||
|
this.updatePlaybackModeDisplay();
|
||||||
|
|
||||||
|
const modeTexts = {
|
||||||
|
'loop': '列表循环',
|
||||||
|
'single': '单曲循环',
|
||||||
|
'random': '随机播放'
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加视觉反馈
|
||||||
|
this.showPlaybackModeFeedback(modeTexts[this.playbackMode]);
|
||||||
|
}
|
||||||
|
|
||||||
|
showPlaybackModeFeedback(modeText) {
|
||||||
|
const feedback = document.createElement('div');
|
||||||
|
feedback.textContent = `播放模式: ${modeText}`;
|
||||||
|
feedback.className = 'visualization-feedback';
|
||||||
|
feedback.style.background = 'rgba(102, 126, 234, 0.9)';
|
||||||
|
|
||||||
|
document.body.appendChild(feedback);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (feedback.parentNode) {
|
||||||
|
feedback.parentNode.removeChild(feedback);
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化播放器
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
new AudioPlayer();
|
||||||
|
});
|
||||||
583
src/style.css
Normal file
583
src/style.css
Normal file
@ -0,0 +1,583 @@
|
|||||||
|
/* 音乐播放器样式 */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 30px;
|
||||||
|
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 900px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player h1 {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 2.5em;
|
||||||
|
font-weight: 300;
|
||||||
|
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 文件选择器 */
|
||||||
|
.file-selector {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 15px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-label {
|
||||||
|
background: linear-gradient(45deg, #667eea, #764ba2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-selector input[type="file"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-label {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 12px 30px;
|
||||||
|
background: linear-gradient(45deg, #ff6b6b, #ee5a24);
|
||||||
|
color: white;
|
||||||
|
border-radius: 25px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
font-weight: 500;
|
||||||
|
box-shadow: 0 4px 15px rgba(238, 90, 36, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-label:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(238, 90, 36, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 波形图容器 */
|
||||||
|
.waveform-container {
|
||||||
|
margin: 20px 0;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 15px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
||||||
|
height: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#waveform {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#waveform:hover {
|
||||||
|
transform: scale(1.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 播放控制和进度条容器 */
|
||||||
|
.playback-progress-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn {
|
||||||
|
padding: 12px 25px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 25px;
|
||||||
|
background: linear-gradient(45deg, #4facfe, #00f2fe);
|
||||||
|
color: white;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: 0 4px 15px rgba(79, 172, 254, 0.4);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 播放/暂停按钮 */
|
||||||
|
.play-pause-btn {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
font-size: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(90deg, #ff6b6b, #4ecdc4);
|
||||||
|
box-shadow: 0 3px 12px rgba(255, 107, 107, 0.4);
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-pause-btn:hover {
|
||||||
|
box-shadow: 0 5px 18px rgba(255, 107, 107, 0.6);
|
||||||
|
transform: scale(1.08);
|
||||||
|
border-color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-pause-btn:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 停止按钮 */
|
||||||
|
.stop-btn {
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
font-size: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(90deg, #667eea, #764ba2);
|
||||||
|
box-shadow: 0 2px 10px rgba(102, 126, 234, 0.4);
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stop-btn:hover {
|
||||||
|
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.6);
|
||||||
|
transform: scale(1.08);
|
||||||
|
border-color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stop-btn:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 导航按钮(上一首/下一首) */
|
||||||
|
.nav-btn {
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
font-size: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(45deg, #4facfe, #00f2fe);
|
||||||
|
box-shadow: 0 2px 10px rgba(79, 172, 254, 0.4);
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn:hover {
|
||||||
|
box-shadow: 0 4px 15px rgba(79, 172, 254, 0.6);
|
||||||
|
transform: scale(1.08);
|
||||||
|
border-color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(79, 172, 254, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 进度条 */
|
||||||
|
.progress-container {
|
||||||
|
flex: 1;
|
||||||
|
user-select: none; /* 防止拖动时选中文本 */
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 3px;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar:hover {
|
||||||
|
height: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(255, 255, 255, 0.2), inset 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #ff6b6b, #4ecdc4);
|
||||||
|
width: 0%;
|
||||||
|
transition: width 0.1s ease;
|
||||||
|
border-radius: 3px;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 1px 3px rgba(255, 107, 107, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: -6px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background: linear-gradient(45deg, #ff6b6b, #4ecdc4);
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: grab;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
z-index: 10;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-handle:hover {
|
||||||
|
transform: translateY(-50%) scale(1.4);
|
||||||
|
box-shadow: 0 3px 8px rgba(255, 107, 107, 0.4);
|
||||||
|
border-width: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
transform: translateY(-50%) scale(1.2);
|
||||||
|
box-shadow: 0 2px 6px rgba(255, 107, 107, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-display {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 底部控制栏 */
|
||||||
|
.bottom-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 播放模式控制 */
|
||||||
|
.playback-mode-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#playbackModeBtn {
|
||||||
|
background: linear-gradient(45deg, #ff6b6b, #ee5a24);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
font-size: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
min-width: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#playbackModeText {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 可视化控制 */
|
||||||
|
.visualization-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#visualizationBtn {
|
||||||
|
background: linear-gradient(45deg, #667eea, #764ba2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#visualizationMode {
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 音量控制 */
|
||||||
|
.volume-control {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-control label {
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#volumeSlider {
|
||||||
|
width: 120px;
|
||||||
|
height: 5px;
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 5px;
|
||||||
|
outline: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#volumeSlider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: linear-gradient(45deg, #ff6b6b, #4ecdc4);
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#volumeSlider::-moz-range-thumb {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: linear-gradient(45deg, #ff6b6b, #4ecdc4);
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#volumeValue {
|
||||||
|
min-width: 40px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 音量按钮 */
|
||||||
|
.volume-btn {
|
||||||
|
background: linear-gradient(45deg, #667eea, #764ba2);
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
border: none;
|
||||||
|
min-width: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-btn:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-btn:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-btn.muted {
|
||||||
|
background: linear-gradient(45deg, #ff6b6b, #ee5a24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-btn.muted:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(255, 107, 107, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 曲目信息 */
|
||||||
|
.track-info {
|
||||||
|
text-align: center;
|
||||||
|
margin: 15px 0;
|
||||||
|
padding: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#trackName {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式设计 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.audio-player {
|
||||||
|
padding: 20px;
|
||||||
|
margin: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player h1 {
|
||||||
|
font-size: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playback-progress-container {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-pause-btn {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stop-btn {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waveform-container {
|
||||||
|
height: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playback-mode-control {
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visualization-control,
|
||||||
|
.volume-control {
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#volumeSlider {
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn {
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.audio-player {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player h1 {
|
||||||
|
font-size: 1.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waveform-container {
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.play-pause-btn {
|
||||||
|
width: 45px;
|
||||||
|
height: 45px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stop-btn {
|
||||||
|
width: 35px;
|
||||||
|
height: 35px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#volumeSlider {
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 动画效果 */
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glow {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 5px rgba(255, 107, 107, 0.5);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 20px rgba(255, 107, 107, 0.8), 0 0 30px rgba(255, 107, 107, 0.6);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 5px rgba(255, 107, 107, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.audio-player.playing .play-pause-btn {
|
||||||
|
animation: glow 2s infinite;
|
||||||
|
background: linear-gradient(90deg, #ff6b6b, #ff8e53);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInOut {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, -50%) scale(0.8);
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
|
}
|
||||||
|
80% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, -50%) scale(0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.visualization-feedback {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
color: white;
|
||||||
|
padding: 15px 25px;
|
||||||
|
border-radius: 25px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
z-index: 1000;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: fadeInOut 1.5s ease-in-out;
|
||||||
|
}
|
||||||
224
src/waveformVisualizer.js
Normal file
224
src/waveformVisualizer.js
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
// 高级波形图可视化器
|
||||||
|
class WaveformVisualizer {
|
||||||
|
constructor(canvas, audioContext, analyser) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.ctx = canvas.getContext('2d');
|
||||||
|
this.audioContext = audioContext;
|
||||||
|
this.analyser = analyser;
|
||||||
|
|
||||||
|
this.bufferLength = this.analyser.frequencyBinCount;
|
||||||
|
this.dataArray = new Uint8Array(this.bufferLength);
|
||||||
|
|
||||||
|
this.setupCanvas();
|
||||||
|
this.visualizationTypes = ['bars', 'wave', 'circular'];
|
||||||
|
this.currentVisualization = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
setupCanvas() {
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
this.canvas.width = rect.width * window.devicePixelRatio;
|
||||||
|
this.canvas.height = rect.height * window.devicePixelRatio;
|
||||||
|
this.ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
|
||||||
|
this.canvas.style.width = rect.width + 'px';
|
||||||
|
this.canvas.style.height = rect.height + 'px';
|
||||||
|
}
|
||||||
|
|
||||||
|
drawBars() {
|
||||||
|
this.analyser.getByteFrequencyData(this.dataArray);
|
||||||
|
|
||||||
|
this.ctx.fillStyle = 'rgb(0, 0, 0)';
|
||||||
|
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
|
const barWidth = (this.canvas.width / this.bufferLength) * 2.5;
|
||||||
|
let barHeight;
|
||||||
|
let x = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < this.bufferLength; i++) {
|
||||||
|
barHeight = (this.dataArray[i] / 255) * this.canvas.height;
|
||||||
|
|
||||||
|
// 创建渐变效果
|
||||||
|
const gradient = this.ctx.createLinearGradient(0, this.canvas.height - barHeight, 0, this.canvas.height);
|
||||||
|
const r = barHeight + (25 * (i / this.bufferLength));
|
||||||
|
const g = 250 * (i / this.bufferLength);
|
||||||
|
const b = 50;
|
||||||
|
|
||||||
|
gradient.addColorStop(0, `rgb(${r}, ${g}, ${b})`);
|
||||||
|
gradient.addColorStop(1, `rgb(${Math.floor(r/2)}, ${Math.floor(g/2)}, ${Math.floor(b/2)})`);
|
||||||
|
|
||||||
|
this.ctx.fillStyle = gradient;
|
||||||
|
this.ctx.fillRect(x, this.canvas.height - barHeight, barWidth, barHeight);
|
||||||
|
|
||||||
|
x += barWidth + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawWave() {
|
||||||
|
this.analyser.getByteTimeDomainData(this.dataArray);
|
||||||
|
|
||||||
|
this.ctx.fillStyle = 'rgb(0, 0, 0)';
|
||||||
|
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
|
this.ctx.lineWidth = 2;
|
||||||
|
this.ctx.strokeStyle = 'rgb(0, 255, 0)';
|
||||||
|
this.ctx.beginPath();
|
||||||
|
|
||||||
|
const sliceWidth = this.canvas.width * 1.0 / this.bufferLength;
|
||||||
|
let x = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < this.bufferLength; i++) {
|
||||||
|
const v = this.dataArray[i] / 128.0;
|
||||||
|
const y = v * this.canvas.height / 2;
|
||||||
|
|
||||||
|
if (i === 0) {
|
||||||
|
this.ctx.moveTo(x, y);
|
||||||
|
} else {
|
||||||
|
this.ctx.lineTo(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
x += sliceWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.ctx.lineTo(this.canvas.width, this.canvas.height / 2);
|
||||||
|
this.ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
drawCircular() {
|
||||||
|
this.drawCompactCircular();
|
||||||
|
}
|
||||||
|
|
||||||
|
drawCompactCircular() {
|
||||||
|
// 更紧凑的圆形图版本 - 圆心在画布中央,大小适中
|
||||||
|
this.analyser.getByteFrequencyData(this.dataArray);
|
||||||
|
|
||||||
|
this.ctx.fillStyle = 'rgb(0, 0, 0)';
|
||||||
|
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
|
// 获取画布的实际显示尺寸
|
||||||
|
const displayWidth = this.canvas.width / window.devicePixelRatio;
|
||||||
|
const displayHeight = this.canvas.height / window.devicePixelRatio;
|
||||||
|
|
||||||
|
// 圆心位置在画布中央
|
||||||
|
const centerX = displayWidth / 2;
|
||||||
|
const centerY = displayHeight / 2;
|
||||||
|
|
||||||
|
// 适中的圆形图大小 - 占画布最小尺寸的30-35%
|
||||||
|
const baseRadius = Math.min(centerX, centerY) * 0.32;
|
||||||
|
const maxAmplitude = Math.min(centerX, centerY) * 0.22;
|
||||||
|
|
||||||
|
// 绘制频谱线条 - 平衡视觉效果和性能
|
||||||
|
const step = Math.max(1, Math.floor(this.bufferLength / 128));
|
||||||
|
|
||||||
|
for (let i = 0; i < this.bufferLength; i += step) {
|
||||||
|
const angle = (i / this.bufferLength) * Math.PI * 2;
|
||||||
|
const amplitude = (this.dataArray[i] / 255) * maxAmplitude;
|
||||||
|
|
||||||
|
// 起始点(基础圆上)
|
||||||
|
const x1 = centerX + Math.cos(angle) * baseRadius;
|
||||||
|
const y1 = centerY + Math.sin(angle) * baseRadius;
|
||||||
|
|
||||||
|
// 结束点(根据频率数据延伸)
|
||||||
|
const x2 = centerX + Math.cos(angle) * (baseRadius + amplitude);
|
||||||
|
const y2 = centerY + Math.sin(angle) * (baseRadius + amplitude);
|
||||||
|
|
||||||
|
// 根据频率强度计算颜色
|
||||||
|
const intensity = this.dataArray[i] / 255;
|
||||||
|
const hue = (i / this.bufferLength) * 360;
|
||||||
|
const saturation = 75 + (intensity * 25);
|
||||||
|
const lightness = 50 + (intensity * 30);
|
||||||
|
|
||||||
|
this.ctx.strokeStyle = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||||||
|
this.ctx.lineWidth = 1 + (intensity * 1.5);
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.moveTo(x1, y1);
|
||||||
|
this.ctx.lineTo(x2, y2);
|
||||||
|
this.ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawCircular() {
|
||||||
|
this.analyser.getByteFrequencyData(this.dataArray);
|
||||||
|
|
||||||
|
this.ctx.fillStyle = 'rgb(0, 0, 0)';
|
||||||
|
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
|
// 获取画布的实际显示尺寸(考虑设备像素比)
|
||||||
|
const displayWidth = this.canvas.width / window.devicePixelRatio;
|
||||||
|
const displayHeight = this.canvas.height / window.devicePixelRatio;
|
||||||
|
|
||||||
|
// 圆心位置在画布中央
|
||||||
|
const centerX = displayWidth / 2;
|
||||||
|
const centerY = displayHeight / 2;
|
||||||
|
|
||||||
|
// 缩小圆形图大小,使用较小的半径
|
||||||
|
const baseRadius = Math.min(centerX, centerY) * 0.5; // 从0.8改为0.5,缩小一半
|
||||||
|
const maxAmplitude = Math.min(centerX, centerY) * 0.5; // 最大振幅也相应缩小
|
||||||
|
|
||||||
|
// 绘制内部基础圆形(可选,增加层次感)
|
||||||
|
this.ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
|
||||||
|
this.ctx.lineWidth = 1;
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.arc(centerX, centerY, baseRadius, 0, 2 * Math.PI);
|
||||||
|
this.ctx.stroke();
|
||||||
|
|
||||||
|
// 绘制频谱线条
|
||||||
|
const step = Math.max(1, Math.floor(this.bufferLength / 128)); // 减少线条数量,提高性能
|
||||||
|
|
||||||
|
for (let i = 0; i < this.bufferLength; i += step) {
|
||||||
|
const angle = (i / this.bufferLength) * Math.PI * 2;
|
||||||
|
const amplitude = (this.dataArray[i] / 255) * maxAmplitude;
|
||||||
|
|
||||||
|
// 起始点(基础圆上)
|
||||||
|
const x1 = centerX + Math.cos(angle) * baseRadius;
|
||||||
|
const y1 = centerY + Math.sin(angle) * baseRadius;
|
||||||
|
|
||||||
|
// 结束点(根据频率数据延伸)
|
||||||
|
const x2 = centerX + Math.cos(angle) * (baseRadius + amplitude);
|
||||||
|
const y2 = centerY + Math.sin(angle) * (baseRadius + amplitude);
|
||||||
|
|
||||||
|
// 根据频率数据计算颜色
|
||||||
|
const intensity = this.dataArray[i] / 255;
|
||||||
|
const hue = (i / this.bufferLength) * 360; // 彩虹色调
|
||||||
|
const saturation = 80 + (intensity * 20); // 饱和度随强度变化
|
||||||
|
const lightness = 40 + (intensity * 40); // 亮度随强度变化
|
||||||
|
|
||||||
|
this.ctx.strokeStyle = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
|
||||||
|
this.ctx.lineWidth = 1.5 + (intensity * 1.5); // 线条宽度随强度变化
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.moveTo(x1, y1);
|
||||||
|
this.ctx.lineTo(x2, y2);
|
||||||
|
this.ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制中心点(可选,增加视觉焦点)
|
||||||
|
this.ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
|
||||||
|
this.ctx.beginPath();
|
||||||
|
this.ctx.arc(centerX, centerY, 2, 0, 2 * Math.PI);
|
||||||
|
this.ctx.fill();
|
||||||
|
}
|
||||||
|
|
||||||
|
draw() {
|
||||||
|
switch (this.visualizationTypes[this.currentVisualization]) {
|
||||||
|
case 'bars':
|
||||||
|
this.drawBars();
|
||||||
|
break;
|
||||||
|
case 'wave':
|
||||||
|
this.drawWave();
|
||||||
|
break;
|
||||||
|
case 'circular':
|
||||||
|
this.drawCircular();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
this.drawBars();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
nextVisualization() {
|
||||||
|
this.currentVisualization = (this.currentVisualization + 1) % this.visualizationTypes.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentVisualization() {
|
||||||
|
return this.visualizationTypes[this.currentVisualization];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WaveformVisualizer;
|
||||||
13
vite.config.js
Normal file
13
vite.config.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
// Vite配置选项
|
||||||
|
server: {
|
||||||
|
port: 3000,
|
||||||
|
open: true
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
outDir: 'dist',
|
||||||
|
assetsDir: 'assets'
|
||||||
|
}
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user