1188 lines
50 KiB
HTML
1188 lines
50 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||
<meta name="theme-color" content="#ff8c42">
|
||
<meta name="description" content="MyMusic - 现代化音乐搜索播放器">
|
||
<link rel="manifest" href="data:application/json;base64,eyJuYW1lIjoiTXlNdXNpYyIsInNob3J0X25hbWUiOiJNeU11c2ljIiwic3RhcnRfdXJsIjoiLiIsImRpc3BsYXkiOiJzdGFuZGFsb25lIiwiYmFja2dyb3VuZF9jb2xvciI6IiNmZmY4ZjIiLCJ0aGVtZV9jb2xvciI6IiNmZjhjNDIifQ==">
|
||
<title>MyMusic - 音乐搜索播放器</title>
|
||
|
||
<!-- Tailwind CSS CDN -->
|
||
<script src="https://cdn.tailwindcss.com"></script>
|
||
|
||
<!-- Font Awesome CDN -->
|
||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||
|
||
<!-- React 和 ReactDOM CDN -->
|
||
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
|
||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
|
||
|
||
<!-- Babel Standalone for JSX -->
|
||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||
|
||
<style>
|
||
/* 自定义滚动条样式 - 苹果风格 */
|
||
::-webkit-scrollbar {
|
||
width: 8px;
|
||
height: 8px;
|
||
}
|
||
|
||
::-webkit-scrollbar-track {
|
||
background: #f1f1f1;
|
||
border-radius: 10px;
|
||
}
|
||
|
||
::-webkit-scrollbar-thumb {
|
||
background: #ff8c42;
|
||
border-radius: 10px;
|
||
}
|
||
|
||
::-webkit-scrollbar-thumb:hover {
|
||
background: #ff7722;
|
||
}
|
||
|
||
/* 毛玻璃效果 */
|
||
.glass-effect {
|
||
background: rgba(255, 255, 255, 0.85);
|
||
backdrop-filter: blur(10px);
|
||
-webkit-backdrop-filter: blur(10px);
|
||
}
|
||
|
||
/* 播放器固定底部 */
|
||
.player-fixed {
|
||
position: fixed;
|
||
bottom: 0;
|
||
left: 0;
|
||
right: 0;
|
||
z-index: 50;
|
||
}
|
||
|
||
/* 平滑过渡 */
|
||
.smooth-transition {
|
||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
|
||
/* 封面旋转动画 */
|
||
@keyframes rotate {
|
||
from { transform: rotate(0deg); }
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
|
||
.rotating {
|
||
animation: rotate 20s linear infinite;
|
||
}
|
||
|
||
.paused {
|
||
animation-play-state: paused;
|
||
}
|
||
|
||
/* 苹果风格按钮 */
|
||
.apple-button {
|
||
background: linear-gradient(135deg, #ff8c42 0%, #ff6b35 100%);
|
||
border: none;
|
||
box-shadow: 0 4px 15px rgba(255, 140, 66, 0.3);
|
||
}
|
||
|
||
.apple-button:hover {
|
||
box-shadow: 0 6px 20px rgba(255, 140, 66, 0.4);
|
||
transform: translateY(-2px);
|
||
}
|
||
|
||
/* 卡片阴影 */
|
||
.card-shadow {
|
||
box-shadow: 0 2px 20px rgba(0, 0, 0, 0.08);
|
||
}
|
||
|
||
/* 隐藏默认音频控件 */
|
||
audio {
|
||
display: none;
|
||
}
|
||
|
||
/* Toast通知动画 */
|
||
@keyframes slideInRight {
|
||
from {
|
||
transform: translateX(100%);
|
||
opacity: 0;
|
||
}
|
||
to {
|
||
transform: translateX(0);
|
||
opacity: 1;
|
||
}
|
||
}
|
||
|
||
@keyframes slideOutRight {
|
||
from {
|
||
transform: translateX(0);
|
||
opacity: 1;
|
||
}
|
||
to {
|
||
transform: translateX(100%);
|
||
opacity: 0;
|
||
}
|
||
}
|
||
|
||
.toast-enter {
|
||
animation: slideInRight 0.3s ease-out;
|
||
}
|
||
|
||
.toast-exit {
|
||
animation: slideOutRight 0.3s ease-in;
|
||
}
|
||
|
||
/* 加载动画 */
|
||
@keyframes pulse {
|
||
0%, 100% {
|
||
opacity: 1;
|
||
}
|
||
50% {
|
||
opacity: 0.5;
|
||
}
|
||
}
|
||
|
||
.loading-pulse {
|
||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||
}
|
||
|
||
/* 按钮点击涟漪效果 */
|
||
.ripple {
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.ripple::after {
|
||
content: '';
|
||
position: absolute;
|
||
top: 50%;
|
||
left: 50%;
|
||
width: 0;
|
||
height: 0;
|
||
border-radius: 50%;
|
||
background: rgba(255, 255, 255, 0.5);
|
||
transform: translate(-50%, -50%);
|
||
transition: width 0.6s, height 0.6s;
|
||
}
|
||
|
||
.ripple:active::after {
|
||
width: 200px;
|
||
height: 200px;
|
||
}
|
||
|
||
/* 移动端优化 */
|
||
@media (max-width: 768px) {
|
||
/* 播放器在移动端的优化 */
|
||
.player-fixed .max-w-7xl {
|
||
padding-left: 1rem;
|
||
padding-right: 1rem;
|
||
}
|
||
|
||
/* 缩小按钮间距 */
|
||
.player-fixed .flex.gap-2 {
|
||
gap: 0.25rem;
|
||
}
|
||
|
||
/* 隐藏音量控制在小屏幕 */
|
||
.volume-control {
|
||
display: none;
|
||
}
|
||
|
||
/* Toast在移动端位置调整 */
|
||
.fixed.top-20.right-4 {
|
||
top: 5rem;
|
||
right: 0.5rem;
|
||
left: 0.5rem;
|
||
}
|
||
|
||
/* 搜索栏移动端优化 */
|
||
.search-wrapper {
|
||
flex-direction: column;
|
||
}
|
||
|
||
/* 卡片在移动端的优化 */
|
||
.music-card {
|
||
padding: 0.75rem;
|
||
}
|
||
|
||
/* 专辑封面在移动端缩小 */
|
||
.album-cover-mobile {
|
||
width: 3.5rem;
|
||
height: 3.5rem;
|
||
}
|
||
}
|
||
|
||
/* 加载骨架屏 */
|
||
.skeleton {
|
||
background: linear-gradient(
|
||
90deg,
|
||
#f0f0f0 25%,
|
||
#e0e0e0 50%,
|
||
#f0f0f0 75%
|
||
);
|
||
background-size: 200% 100%;
|
||
animation: loading 1.5s ease-in-out infinite;
|
||
}
|
||
|
||
@keyframes loading {
|
||
0% {
|
||
background-position: 200% 0;
|
||
}
|
||
100% {
|
||
background-position: -200% 0;
|
||
}
|
||
}
|
||
|
||
/* 优化滚动性能 */
|
||
.scroll-smooth {
|
||
scroll-behavior: smooth;
|
||
-webkit-overflow-scrolling: touch;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body class="bg-gradient-to-br from-orange-50 to-pink-50 min-h-screen">
|
||
<div id="root"></div>
|
||
|
||
<script type="text/babel">
|
||
const { useState, useEffect, useRef, useCallback, useMemo } = React;
|
||
|
||
// ==================== API 配置 ====================
|
||
const API_BASE = 'https://music-dl.sayqz.com';
|
||
|
||
// ==================== API 请求函数 ====================
|
||
|
||
/**
|
||
* 聚合搜索音乐
|
||
* @param {string} keyword - 搜索关键词
|
||
* @returns {Promise<Array>} 音乐列表
|
||
*/
|
||
const aggregateSearch = async (keyword) => {
|
||
try {
|
||
const response = await fetch(`${API_BASE}/api/?type=aggregateSearch&keyword=${encodeURIComponent(keyword)}`);
|
||
const data = await response.json();
|
||
if (data.code === 200) {
|
||
return data.data.results || [];
|
||
}
|
||
return [];
|
||
} catch (error) {
|
||
console.error('搜索失败:', error);
|
||
return [];
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 获取歌曲详细信息
|
||
* @param {string} source - 平台
|
||
* @param {string} id - 歌曲ID
|
||
* @returns {Promise<Object>} 歌曲信息
|
||
*/
|
||
const getSongInfo = async (source, id) => {
|
||
try {
|
||
const response = await fetch(`${API_BASE}/api/?source=${source}&id=${id}&type=info`);
|
||
const data = await response.json();
|
||
if (data.code === 200) {
|
||
return data.data;
|
||
}
|
||
return null;
|
||
} catch (error) {
|
||
console.error('获取歌曲信息失败:', error);
|
||
return null;
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 下载单个音乐文件
|
||
* @param {Object} song - 歌曲对象
|
||
* @param {Function} showToast - Toast通知函数(可选)
|
||
*/
|
||
const downloadSong = (song, showToast = null) => {
|
||
const url = `${API_BASE}/api/?source=${song.platform}&id=${song.id}&type=url&br=320k`;
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `${song.name} - ${song.artist}.mp3`;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
if (showToast) {
|
||
showToast(`开始下载: ${song.name}`, 'success');
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 批量下载专辑
|
||
* @param {Array} songs - 歌曲列表
|
||
* @param {string} albumName - 专辑名
|
||
* @param {Function} showToast - Toast通知函数(可选)
|
||
*/
|
||
const downloadAlbum = (songs, albumName, showToast = null) => {
|
||
if (showToast) {
|
||
showToast(`开始下载专辑: ${albumName} (${songs.length}首)`, 'success');
|
||
}
|
||
songs.forEach((song, index) => {
|
||
setTimeout(() => {
|
||
downloadSong(song);
|
||
}, index * 1000); // 每秒下载一首,避免过快
|
||
});
|
||
};
|
||
|
||
const formatTime = (seconds) => {
|
||
if (!Number.isFinite(seconds) || seconds < 0) return '0:00';
|
||
const mins = Math.floor(seconds / 60);
|
||
const secs = Math.floor(seconds % 60);
|
||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||
};
|
||
|
||
// ==================== 主应用组件 ====================
|
||
function MusicApp() {
|
||
// 状态管理
|
||
const [searchKeyword, setSearchKeyword] = useState('');
|
||
const [searchResults, setSearchResults] = useState([]);
|
||
const [isSearching, setIsSearching] = useState(false);
|
||
const [playlist, setPlaylist] = useState([]); // 播放列表
|
||
const [currentSong, setCurrentSong] = useState(null); // 当前播放歌曲
|
||
const [isPlaying, setIsPlaying] = useState(false);
|
||
const [volume, setVolume] = useState(0.7);
|
||
const [activeTab, setActiveTab] = useState('search'); // search | playlist
|
||
const [albumView, setAlbumView] = useState(null); // 查看专辑详情
|
||
const [playMode, setPlayMode] = useState('loop'); // loop | single | shuffle
|
||
const [toasts, setToasts] = useState([]); // Toast通知列表
|
||
const [showLyrics, setShowLyrics] = useState(false); // 显示歌词
|
||
const [lyrics, setLyrics] = useState(''); // 歌词内容
|
||
|
||
const audioRef = useRef(null);
|
||
const currentTimeRef = useRef(0);
|
||
const durationRef = useRef(0);
|
||
const progressRef = useRef(null);
|
||
const currentTimeTextRef = useRef(null);
|
||
const durationTextRef = useRef(null);
|
||
|
||
// 进度与时间显示改为直接操作 DOM,避免频繁渲染
|
||
const updateCurrentTimeUI = useCallback((nextTime) => {
|
||
const safeTime = Number.isFinite(nextTime) && nextTime >= 0 ? nextTime : 0;
|
||
const safeDuration = Number.isFinite(durationRef.current) && durationRef.current >= 0
|
||
? durationRef.current
|
||
: 0;
|
||
const cappedTime = safeDuration > 0 ? Math.min(safeTime, safeDuration) : safeTime;
|
||
currentTimeRef.current = cappedTime;
|
||
|
||
if (currentTimeTextRef.current) {
|
||
currentTimeTextRef.current.textContent = formatTime(cappedTime);
|
||
}
|
||
|
||
const progress = progressRef.current;
|
||
if (progress) {
|
||
progress.max = String(safeDuration);
|
||
progress.value = String(safeDuration > 0 ? Math.min(cappedTime, safeDuration) : 0);
|
||
}
|
||
}, []);
|
||
|
||
const updateDurationUI = useCallback((nextDuration) => {
|
||
const safeDuration = Number.isFinite(nextDuration) && nextDuration >= 0 ? nextDuration : 0;
|
||
durationRef.current = safeDuration;
|
||
|
||
const safeTime = Number.isFinite(currentTimeRef.current) && currentTimeRef.current >= 0
|
||
? currentTimeRef.current
|
||
: 0;
|
||
const cappedTime = safeDuration > 0 ? Math.min(safeTime, safeDuration) : safeTime;
|
||
currentTimeRef.current = cappedTime;
|
||
|
||
if (currentTimeTextRef.current) {
|
||
currentTimeTextRef.current.textContent = formatTime(cappedTime);
|
||
}
|
||
if (durationTextRef.current) {
|
||
durationTextRef.current.textContent = formatTime(safeDuration);
|
||
}
|
||
|
||
const progress = progressRef.current;
|
||
if (progress) {
|
||
progress.max = String(safeDuration);
|
||
progress.value = String(safeDuration > 0 ? Math.min(cappedTime, safeDuration) : 0);
|
||
}
|
||
}, []);
|
||
|
||
const resetTimeUI = useCallback(() => {
|
||
currentTimeRef.current = 0;
|
||
durationRef.current = 0;
|
||
|
||
if (currentTimeTextRef.current) {
|
||
currentTimeTextRef.current.textContent = formatTime(0);
|
||
}
|
||
if (durationTextRef.current) {
|
||
durationTextRef.current.textContent = formatTime(0);
|
||
}
|
||
|
||
const progress = progressRef.current;
|
||
if (progress) {
|
||
progress.max = '0';
|
||
progress.value = '0';
|
||
}
|
||
}, []);
|
||
|
||
// ==================== Toast通知系统 ====================
|
||
/**
|
||
* 显示Toast通知
|
||
*/
|
||
const showToast = useCallback((message, type = 'info') => {
|
||
const id = Date.now();
|
||
const toast = { id, message, type };
|
||
setToasts(prev => [...prev, toast]);
|
||
|
||
// 3秒后自动移除
|
||
setTimeout(() => {
|
||
setToasts(prev => prev.filter(t => t.id !== id));
|
||
}, 3000);
|
||
}, []);
|
||
|
||
/**
|
||
* 获取歌词
|
||
*/
|
||
const fetchLyrics = useCallback(async (song) => {
|
||
try {
|
||
const response = await fetch(`${API_BASE}/api/?source=${song.platform}&id=${song.id}&type=lrc`);
|
||
const lrcText = await response.text();
|
||
setLyrics(lrcText || '暂无歌词');
|
||
} catch (error) {
|
||
setLyrics('歌词加载失败');
|
||
}
|
||
}, []);
|
||
|
||
// ==================== 搜索处理 ====================
|
||
const handleSearch = useCallback(async () => {
|
||
if (!searchKeyword.trim()) {
|
||
showToast('请输入搜索关键词', 'warning');
|
||
return;
|
||
}
|
||
|
||
setIsSearching(true);
|
||
try {
|
||
const results = await aggregateSearch(searchKeyword);
|
||
setSearchResults(results);
|
||
if (results.length === 0) {
|
||
showToast('未找到相关音乐', 'info');
|
||
} else {
|
||
showToast(`找到 ${results.length} 首歌曲`, 'success');
|
||
}
|
||
} catch (error) {
|
||
showToast('搜索失败,请重试', 'error');
|
||
} finally {
|
||
setIsSearching(false);
|
||
}
|
||
}, [searchKeyword, showToast]);
|
||
|
||
// 回车搜索
|
||
const handleKeyPress = useCallback((e) => {
|
||
if (e.key === 'Enter') {
|
||
handleSearch();
|
||
}
|
||
}, [handleSearch]);
|
||
|
||
// ==================== 播放控制 ====================
|
||
|
||
/**
|
||
* 播放指定歌曲
|
||
*/
|
||
const playSong = useCallback((song) => {
|
||
setCurrentSong(song);
|
||
setIsPlaying(true);
|
||
showToast(`正在播放: ${song.name}`, 'success');
|
||
// 获取歌词
|
||
fetchLyrics(song);
|
||
}, [showToast, fetchLyrics]);
|
||
|
||
/**
|
||
* 添加到播放列表
|
||
*/
|
||
const addToPlaylist = useCallback((song) => {
|
||
// 检查是否已存在
|
||
setPlaylist(prev => {
|
||
const exists = prev.some(s => s.id === song.id && s.platform === song.platform);
|
||
if (!exists) {
|
||
showToast(`已添加: ${song.name}`, 'success');
|
||
return [...prev, song];
|
||
} else {
|
||
showToast('歌曲已在播放列表中', 'info');
|
||
return prev;
|
||
}
|
||
});
|
||
}, [showToast]);
|
||
|
||
/**
|
||
* 从播放列表移除
|
||
*/
|
||
const removeFromPlaylist = useCallback((song) => {
|
||
setPlaylist(prev => prev.filter(s => !(s.id === song.id && s.platform === song.platform)));
|
||
showToast('已从播放列表移除', 'info');
|
||
}, [showToast]);
|
||
|
||
/**
|
||
* 清空播放列表
|
||
*/
|
||
const clearPlaylist = useCallback(() => {
|
||
setPlaylist(prev => {
|
||
if (prev.length === 0) {
|
||
showToast('播放列表已经是空的', 'info');
|
||
return prev;
|
||
}
|
||
showToast('播放列表已清空', 'success');
|
||
return [];
|
||
});
|
||
}, [showToast]);
|
||
|
||
/**
|
||
* 播放整个播放列表
|
||
*/
|
||
const playPlaylist = useCallback(() => {
|
||
setPlaylist(prev => {
|
||
if (prev.length > 0) {
|
||
setCurrentSong(prev[0]);
|
||
setIsPlaying(true);
|
||
}
|
||
return prev;
|
||
});
|
||
}, []);
|
||
|
||
/**
|
||
* 上一曲
|
||
*/
|
||
const playPrevious = useCallback(() => {
|
||
if (!currentSong || playlist.length === 0) return;
|
||
const currentIndex = playlist.findIndex(s => s.id === currentSong.id && s.platform === currentSong.platform);
|
||
const prevIndex = currentIndex > 0 ? currentIndex - 1 : playlist.length - 1;
|
||
const nextSong = playlist[prevIndex];
|
||
setCurrentSong(nextSong);
|
||
setIsPlaying(true);
|
||
fetchLyrics(nextSong);
|
||
}, [currentSong, playlist, fetchLyrics]);
|
||
|
||
/**
|
||
* 下一曲 - 支持不同播放模式
|
||
*/
|
||
const playNext = useCallback(() => {
|
||
if (!currentSong || playlist.length === 0) return;
|
||
|
||
const currentIndex = playlist.findIndex(s => s.id === currentSong.id && s.platform === currentSong.platform);
|
||
let nextSong;
|
||
|
||
if (playMode === 'single') {
|
||
// 单曲循环 - 重新播放当前歌曲
|
||
nextSong = currentSong;
|
||
} else if (playMode === 'shuffle') {
|
||
// 随机播放
|
||
let randomIndex;
|
||
do {
|
||
randomIndex = Math.floor(Math.random() * playlist.length);
|
||
} while (randomIndex === currentIndex && playlist.length > 1);
|
||
nextSong = playlist[randomIndex];
|
||
} else {
|
||
// 列表循环
|
||
const nextIndex = (currentIndex + 1) % playlist.length;
|
||
nextSong = playlist[nextIndex];
|
||
}
|
||
|
||
setCurrentSong(nextSong);
|
||
setIsPlaying(true);
|
||
fetchLyrics(nextSong);
|
||
}, [currentSong, playlist, playMode, fetchLyrics]);
|
||
|
||
/**
|
||
* 切换播放模式
|
||
*/
|
||
const togglePlayMode = useCallback(() => {
|
||
const modes = ['loop', 'single', 'shuffle'];
|
||
const currentModeIndex = modes.indexOf(playMode);
|
||
const nextMode = modes[(currentModeIndex + 1) % modes.length];
|
||
setPlayMode(nextMode);
|
||
|
||
const modeNames = { loop: '列表循环', single: '单曲循环', shuffle: '随机播放' };
|
||
showToast(`播放模式: ${modeNames[nextMode]}`, 'info');
|
||
}, [playMode, showToast]);
|
||
|
||
/**
|
||
* 切换播放/暂停
|
||
*/
|
||
const togglePlayPause = useCallback(() => {
|
||
if (!currentSong) return;
|
||
setIsPlaying(prev => !prev);
|
||
}, [currentSong]);
|
||
|
||
// ==================== 音频事件处理 ====================
|
||
useEffect(() => {
|
||
const audio = audioRef.current;
|
||
if (!audio) return;
|
||
|
||
const updateDuration = () => {
|
||
updateDurationUI(audio.duration);
|
||
};
|
||
|
||
const handleTimeUpdate = () => {
|
||
updateCurrentTimeUI(audio.currentTime);
|
||
};
|
||
|
||
const handleEnded = () => {
|
||
playNext(); // 自动播放下一曲
|
||
};
|
||
|
||
audio.addEventListener('loadedmetadata', updateDuration);
|
||
audio.addEventListener('durationchange', updateDuration);
|
||
audio.addEventListener('timeupdate', handleTimeUpdate);
|
||
audio.addEventListener('ended', handleEnded);
|
||
|
||
return () => {
|
||
audio.removeEventListener('loadedmetadata', updateDuration);
|
||
audio.removeEventListener('durationchange', updateDuration);
|
||
audio.removeEventListener('timeupdate', handleTimeUpdate);
|
||
audio.removeEventListener('ended', handleEnded);
|
||
};
|
||
}, [playNext, updateCurrentTimeUI, updateDurationUI]);
|
||
|
||
// 当前歌曲变化时更新音频源
|
||
useEffect(() => {
|
||
if (currentSong && audioRef.current) {
|
||
const url = `${API_BASE}/api/?source=${currentSong.platform}&id=${currentSong.id}&type=url&br=320k`;
|
||
const audio = audioRef.current;
|
||
audio.src = url;
|
||
audio.currentTime = 0;
|
||
audio.volume = volume;
|
||
resetTimeUI();
|
||
audio.load();
|
||
}
|
||
}, [currentSong, resetTimeUI]);
|
||
|
||
// 控制播放/暂停
|
||
useEffect(() => {
|
||
if (!audioRef.current) return;
|
||
if (isPlaying) {
|
||
audioRef.current.play().catch(err => console.error('播放失败:', err));
|
||
} else {
|
||
audioRef.current.pause();
|
||
}
|
||
}, [isPlaying, currentSong]);
|
||
|
||
// 音量控制
|
||
useEffect(() => {
|
||
if (audioRef.current) {
|
||
audioRef.current.volume = volume;
|
||
}
|
||
}, [volume]);
|
||
|
||
// ==================== 专辑功能 ====================
|
||
|
||
/**
|
||
* 查看专辑歌曲列表
|
||
*/
|
||
const viewAlbum = useCallback((albumName) => {
|
||
const albumSongs = searchResults.filter(s => s.album === albumName);
|
||
setAlbumView({ name: albumName, songs: albumSongs });
|
||
}, [searchResults]);
|
||
|
||
/**
|
||
* 播放专辑所有歌曲
|
||
*/
|
||
const playAlbum = useCallback((albumSongs) => {
|
||
albumSongs.forEach(song => addToPlaylist(song));
|
||
if (albumSongs.length > 0) {
|
||
setCurrentSong(albumSongs[0]);
|
||
setIsPlaying(true);
|
||
}
|
||
}, [addToPlaylist]);
|
||
|
||
/**
|
||
* 获取平台图标
|
||
*/
|
||
const getPlatformIcon = (platform) => {
|
||
const icons = {
|
||
netease: '🎵',
|
||
qq: '🎶',
|
||
kuwo: '🎸'
|
||
};
|
||
return icons[platform] || '🎧';
|
||
};
|
||
|
||
// ==================== 渲染组件 ====================
|
||
|
||
/**
|
||
* 搜索栏组件
|
||
*/
|
||
const SearchBar = useMemo(() => (
|
||
<div className="mb-6">
|
||
<div className="flex gap-2">
|
||
<div className="flex-1 relative">
|
||
<input
|
||
type="text"
|
||
value={searchKeyword}
|
||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||
onKeyPress={handleKeyPress}
|
||
placeholder="搜索歌曲、歌手或专辑..."
|
||
className="w-full px-4 py-3 pl-12 rounded-2xl border-0 glass-effect focus:outline-none focus:ring-2 focus:ring-orange-400 text-gray-700 placeholder-gray-400"
|
||
/>
|
||
<i className="fas fa-search absolute left-4 top-1/2 transform -translate-y-1/2 text-gray-400"></i>
|
||
</div>
|
||
<button
|
||
onClick={handleSearch}
|
||
disabled={isSearching}
|
||
className="apple-button text-white px-8 py-3 rounded-2xl font-medium smooth-transition disabled:opacity-50 ripple"
|
||
>
|
||
{isSearching ? <i className="fas fa-spinner fa-spin"></i> : '搜索'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
), [searchKeyword, isSearching, handleSearch, handleKeyPress]);
|
||
|
||
/**
|
||
* 音乐卡片组件
|
||
*/
|
||
const MusicCard = ({ song, showActions = true }) => (
|
||
<div className="glass-effect rounded-xl p-4 card-shadow smooth-transition hover:shadow-lg mb-3">
|
||
<div className="flex items-center gap-4">
|
||
{/* 封面 */}
|
||
<img
|
||
src={song.pic || 'https://via.placeholder.com/80?text=No+Cover'}
|
||
alt={song.name}
|
||
className="w-20 h-20 rounded-lg object-cover"
|
||
onError={(e) => e.target.src = 'https://via.placeholder.com/80?text=No+Cover'}
|
||
/>
|
||
|
||
{/* 歌曲信息 */}
|
||
<div className="flex-1 min-w-0">
|
||
<h3 className="font-semibold text-gray-800 truncate">{song.name}</h3>
|
||
<p className="text-sm text-gray-500 truncate">{song.artist}</p>
|
||
<div className="flex items-center gap-2 mt-1">
|
||
<span
|
||
className="text-xs text-orange-600 hover:underline cursor-pointer"
|
||
onClick={() => viewAlbum(song.album)}
|
||
>
|
||
{song.album}
|
||
</span>
|
||
<span className="text-xs">{getPlatformIcon(song.platform)}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 操作按钮 */}
|
||
{showActions && (
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => playSong(song)}
|
||
className="w-10 h-10 rounded-full bg-gradient-to-br from-orange-400 to-orange-500 text-white flex items-center justify-center smooth-transition hover:scale-110"
|
||
title="播放"
|
||
>
|
||
<i className="fas fa-play text-sm"></i>
|
||
</button>
|
||
<button
|
||
onClick={() => addToPlaylist(song)}
|
||
className="w-10 h-10 rounded-full bg-gray-100 text-gray-600 flex items-center justify-center smooth-transition hover:bg-gray-200"
|
||
title="添加到播放列表"
|
||
>
|
||
<i className="fas fa-plus"></i>
|
||
</button>
|
||
<button
|
||
onClick={() => downloadSong(song, showToast)}
|
||
className="w-10 h-10 rounded-full bg-gray-100 text-gray-600 flex items-center justify-center smooth-transition hover:bg-gray-200 ripple"
|
||
title="下载"
|
||
>
|
||
<i className="fas fa-download"></i>
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
/**
|
||
* 专辑视图组件
|
||
*/
|
||
const AlbumView = useMemo(() => {
|
||
if (!albumView) return null;
|
||
return (
|
||
<div className="glass-effect rounded-2xl p-6 card-shadow mb-6">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="text-2xl font-bold text-gray-800">
|
||
<i className="fas fa-compact-disc mr-2 text-orange-500"></i>
|
||
{albumView.name}
|
||
</h2>
|
||
<button
|
||
onClick={() => setAlbumView(null)}
|
||
className="text-gray-500 hover:text-gray-700"
|
||
>
|
||
<i className="fas fa-times text-xl"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex gap-3 mb-4">
|
||
<button
|
||
onClick={() => playAlbum(albumView.songs)}
|
||
className="apple-button text-white px-6 py-2 rounded-xl smooth-transition ripple"
|
||
>
|
||
<i className="fas fa-play mr-2"></i>播放全部
|
||
</button>
|
||
<button
|
||
onClick={() => downloadAlbum(albumView.songs, albumView.name, showToast)}
|
||
className="bg-gray-100 text-gray-700 px-6 py-2 rounded-xl smooth-transition hover:bg-gray-200 ripple"
|
||
>
|
||
<i className="fas fa-download mr-2"></i>下载专辑
|
||
</button>
|
||
</div>
|
||
|
||
<div className="max-h-96 overflow-y-auto scroll-smooth">
|
||
{albumView.songs.map((song, index) => (
|
||
<MusicCard key={`${song.platform}-${song.id}-${index}`} song={song} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}, [albumView, playAlbum, showToast]);
|
||
|
||
/**
|
||
* 加载骨架屏组件
|
||
*/
|
||
const LoadingSkeleton = useMemo(() => (
|
||
<div className="glass-effect rounded-2xl p-6 card-shadow">
|
||
<div className="h-6 w-32 skeleton rounded mb-4"></div>
|
||
{[1, 2, 3, 4, 5].map(i => (
|
||
<div key={i} className="flex items-center gap-4 mb-3 p-4">
|
||
<div className="w-20 h-20 skeleton rounded-lg"></div>
|
||
<div className="flex-1">
|
||
<div className="h-4 skeleton rounded mb-2"></div>
|
||
<div className="h-3 w-3/4 skeleton rounded"></div>
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<div className="w-10 h-10 skeleton rounded-full"></div>
|
||
<div className="w-10 h-10 skeleton rounded-full"></div>
|
||
<div className="w-10 h-10 skeleton rounded-full"></div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
), []);
|
||
|
||
/**
|
||
* 搜索结果列表
|
||
*/
|
||
const SearchResults = useMemo(() => (
|
||
<div>
|
||
{AlbumView}
|
||
|
||
{isSearching ? (
|
||
LoadingSkeleton
|
||
) : searchResults.length > 0 ? (
|
||
<div className="glass-effect rounded-2xl p-6 card-shadow">
|
||
<h2 className="text-xl font-bold text-gray-800 mb-4">
|
||
搜索结果 ({searchResults.length})
|
||
</h2>
|
||
<div className="max-h-[calc(100vh-400px)] overflow-y-auto scroll-smooth">
|
||
{searchResults.map((song, index) => (
|
||
<MusicCard key={`${song.platform}-${song.id}-${index}`} song={song} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="glass-effect rounded-2xl p-12 card-shadow text-center text-gray-400">
|
||
<i className="fas fa-music text-6xl mb-4 loading-pulse"></i>
|
||
<p className="text-lg">搜索你喜欢的音乐</p>
|
||
<p className="text-sm mt-2">支持网易云、QQ音乐、酷我音乐</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
), [AlbumView, isSearching, searchResults, LoadingSkeleton]);
|
||
|
||
/**
|
||
* 播放列表视图
|
||
*/
|
||
const PlaylistView = useMemo(() => (
|
||
<div className="glass-effect rounded-2xl p-6 card-shadow">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h2 className="text-xl font-bold text-gray-800">
|
||
我的播放列表 ({playlist.length})
|
||
</h2>
|
||
<div className="flex gap-2">
|
||
{playlist.length > 0 && (
|
||
<>
|
||
<button
|
||
onClick={playPlaylist}
|
||
className="apple-button text-white px-4 py-2 rounded-xl text-sm smooth-transition ripple"
|
||
>
|
||
<i className="fas fa-play mr-2"></i>播放全部
|
||
</button>
|
||
<button
|
||
onClick={clearPlaylist}
|
||
className="bg-red-100 text-red-600 px-4 py-2 rounded-xl text-sm smooth-transition hover:bg-red-200 ripple"
|
||
>
|
||
<i className="fas fa-trash mr-2"></i>清空
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{playlist.length > 0 ? (
|
||
<div className="max-h-[calc(100vh-400px)] overflow-y-auto scroll-smooth">
|
||
{playlist.map((song, index) => (
|
||
<div key={`${song.platform}-${song.id}-${index}`} className="relative">
|
||
<MusicCard song={song} showActions={false} />
|
||
<button
|
||
onClick={() => removeFromPlaylist(song)}
|
||
className="absolute top-1/2 right-4 transform -translate-y-1/2 w-8 h-8 rounded-full bg-red-100 text-red-600 flex items-center justify-center smooth-transition hover:bg-red-200"
|
||
>
|
||
<i className="fas fa-times text-sm"></i>
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="text-center text-gray-400 py-12">
|
||
<i className="fas fa-list-music text-6xl mb-4"></i>
|
||
<p className="text-lg">播放列表为空</p>
|
||
<p className="text-sm mt-2">添加你喜欢的歌曲到播放列表</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
), [playlist, playPlaylist, clearPlaylist, removeFromPlaylist]);
|
||
|
||
/**
|
||
* Toast通知组件
|
||
*/
|
||
const ToastContainer = useMemo(() => (
|
||
<div className="fixed top-20 right-4 z-50 space-y-2">
|
||
{toasts.map(toast => {
|
||
const colors = {
|
||
success: 'bg-green-500',
|
||
error: 'bg-red-500',
|
||
warning: 'bg-yellow-500',
|
||
info: 'bg-blue-500'
|
||
};
|
||
|
||
return (
|
||
<div
|
||
key={toast.id}
|
||
className={`${colors[toast.type]} text-white px-6 py-3 rounded-xl shadow-lg toast-enter flex items-center gap-2 max-w-sm`}
|
||
>
|
||
<i className={`fas fa-${
|
||
toast.type === 'success' ? 'check-circle' :
|
||
toast.type === 'error' ? 'exclamation-circle' :
|
||
toast.type === 'warning' ? 'exclamation-triangle' :
|
||
'info-circle'
|
||
}`}></i>
|
||
<span>{toast.message}</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
), [toasts]);
|
||
|
||
/**
|
||
* 歌词显示组件
|
||
*/
|
||
const LyricsPanel = () => {
|
||
if (!showLyrics || !currentSong) return null;
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 z-40 flex items-center justify-center p-4" onClick={() => setShowLyrics(false)}>
|
||
<div className="glass-effect rounded-2xl p-6 max-w-2xl w-full max-h-[80vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h3 className="text-xl font-bold text-gray-800">
|
||
<i className="fas fa-music mr-2 text-orange-500"></i>
|
||
{currentSong.name}
|
||
</h3>
|
||
<button
|
||
onClick={() => setShowLyrics(false)}
|
||
className="w-8 h-8 rounded-full bg-gray-100 text-gray-600 flex items-center justify-center smooth-transition hover:bg-gray-200"
|
||
>
|
||
<i className="fas fa-times"></i>
|
||
</button>
|
||
</div>
|
||
<p className="text-sm text-gray-500 mb-4">{currentSong.artist}</p>
|
||
<div className="whitespace-pre-line text-gray-700 leading-relaxed">
|
||
{lyrics || '加载中...'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/**
|
||
* 底部播放器组件
|
||
*/
|
||
const Player = () => {
|
||
if (!currentSong) return null;
|
||
|
||
return (
|
||
<div className="player-fixed glass-effect shadow-2xl border-t border-gray-200">
|
||
<div className="max-w-7xl mx-auto px-4 py-4">
|
||
<div className="flex items-center gap-4">
|
||
{/* 封面和歌曲信息 */}
|
||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||
<img
|
||
src={currentSong.pic || 'https://via.placeholder.com/60?text=Playing'}
|
||
alt={currentSong.name}
|
||
className={`w-14 h-14 rounded-lg object-cover ${isPlaying ? 'rotating' : 'paused'}`}
|
||
onError={(e) => e.target.src = 'https://via.placeholder.com/60?text=Playing'}
|
||
/>
|
||
<div className="min-w-0">
|
||
<h4 className="font-semibold text-gray-800 truncate">{currentSong.name}</h4>
|
||
<p className="text-sm text-gray-500 truncate">{currentSong.artist}</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 控制按钮 */}
|
||
<div className="flex items-center gap-2">
|
||
{/* 播放模式 */}
|
||
<button
|
||
onClick={togglePlayMode}
|
||
className="w-9 h-9 rounded-full bg-gray-100 text-gray-600 flex items-center justify-center smooth-transition hover:bg-gray-200"
|
||
title={`当前模式: ${playMode === 'loop' ? '列表循环' : playMode === 'single' ? '单曲循环' : '随机播放'}`}
|
||
>
|
||
<i className={`fas fa-${
|
||
playMode === 'loop' ? 'repeat' :
|
||
playMode === 'single' ? 'redo' :
|
||
'random'
|
||
}`}></i>
|
||
</button>
|
||
|
||
<button
|
||
onClick={playPrevious}
|
||
className="w-10 h-10 rounded-full bg-gray-100 text-gray-600 flex items-center justify-center smooth-transition hover:bg-gray-200 ripple"
|
||
>
|
||
<i className="fas fa-step-backward"></i>
|
||
</button>
|
||
<button
|
||
onClick={togglePlayPause}
|
||
className="w-12 h-12 rounded-full apple-button text-white flex items-center justify-center smooth-transition hover:scale-110 ripple"
|
||
>
|
||
<i className={`fas fa-${isPlaying ? 'pause' : 'play'}`}></i>
|
||
</button>
|
||
<button
|
||
onClick={playNext}
|
||
className="w-10 h-10 rounded-full bg-gray-100 text-gray-600 flex items-center justify-center smooth-transition hover:bg-gray-200 ripple"
|
||
>
|
||
<i className="fas fa-step-forward"></i>
|
||
</button>
|
||
|
||
{/* 歌词按钮 */}
|
||
<button
|
||
onClick={() => setShowLyrics(true)}
|
||
className="w-9 h-9 rounded-full bg-gray-100 text-gray-600 flex items-center justify-center smooth-transition hover:bg-gray-200"
|
||
title="显示歌词"
|
||
>
|
||
<i className="fas fa-align-left"></i>
|
||
</button>
|
||
</div>
|
||
|
||
{/* 进度条 */}
|
||
<div className="flex-1 flex items-center gap-3">
|
||
<span
|
||
ref={currentTimeTextRef}
|
||
className="text-xs text-gray-500 w-10 text-right"
|
||
>
|
||
{formatTime(currentTimeRef.current)}
|
||
</span>
|
||
<input
|
||
ref={progressRef}
|
||
type="range"
|
||
min="0"
|
||
max={durationRef.current}
|
||
defaultValue={0}
|
||
onChange={(e) => {
|
||
const nextTime = Number(e.target.value);
|
||
if (!Number.isFinite(nextTime)) return;
|
||
if (audioRef.current) {
|
||
audioRef.current.currentTime = nextTime;
|
||
}
|
||
updateCurrentTimeUI(nextTime);
|
||
}}
|
||
className="flex-1 h-1 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-orange-500"
|
||
/>
|
||
<span
|
||
ref={durationTextRef}
|
||
className="text-xs text-gray-500 w-10"
|
||
>
|
||
{formatTime(durationRef.current)}
|
||
</span>
|
||
</div>
|
||
|
||
{/* 音量控制 */}
|
||
<div className="flex items-center gap-2 volume-control">
|
||
<i className="fas fa-volume-up text-gray-500"></i>
|
||
<input
|
||
type="range"
|
||
min="0"
|
||
max="1"
|
||
step="0.01"
|
||
value={volume}
|
||
onChange={(e) => setVolume(parseFloat(e.target.value))}
|
||
className="w-24 h-1 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-orange-500"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ==================== 主渲染 ====================
|
||
return (
|
||
<div className="min-h-screen pb-32">
|
||
{/* Toast通知 */}
|
||
{ToastContainer}
|
||
|
||
{/* 歌词面板 */}
|
||
<LyricsPanel />
|
||
|
||
{/* 顶部导航 */}
|
||
<div className="glass-effect sticky top-0 z-40 border-b border-gray-200">
|
||
<div className="max-w-7xl mx-auto px-4 py-4">
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h1 className="text-3xl font-bold bg-gradient-to-r from-orange-500 to-pink-500 bg-clip-text text-transparent">
|
||
<i className="fas fa-music mr-2"></i>
|
||
MyMusic
|
||
</h1>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => setActiveTab('search')}
|
||
className={`px-6 py-2 rounded-xl smooth-transition font-medium ripple ${
|
||
activeTab === 'search'
|
||
? 'apple-button text-white'
|
||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||
}`}
|
||
>
|
||
<i className="fas fa-search mr-2"></i>搜索
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('playlist')}
|
||
className={`px-6 py-2 rounded-xl smooth-transition font-medium ripple ${
|
||
activeTab === 'playlist'
|
||
? 'apple-button text-white'
|
||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||
}`}
|
||
>
|
||
<i className="fas fa-list mr-2"></i>播放列表
|
||
{playlist.length > 0 && (
|
||
<span className="ml-1 px-2 py-0.5 bg-white bg-opacity-30 rounded-full text-xs">
|
||
{playlist.length}
|
||
</span>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{activeTab === 'search' && SearchBar}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 主内容区 */}
|
||
<div className="max-w-7xl mx-auto px-4 py-6">
|
||
{activeTab === 'search' ? SearchResults : PlaylistView}
|
||
</div>
|
||
|
||
{/* 底部播放器 */}
|
||
<Player />
|
||
|
||
{/* 隐藏的音频元素 */}
|
||
<audio ref={audioRef} preload="metadata" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ==================== 渲染到DOM ====================
|
||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||
root.render(<MusicApp />);
|
||
</script>
|
||
</body>
|
||
</html>
|