Files
Mymusic3/index.html
史悦 33e3ec714e feat(ui): add clear cache and optimize media session
- Add button in side drawer settings to clear local cache data
- Refactor Media Session position updates to trigger on specific events (seek, play/pause, load) instead of every time update
- Add finite number validation for seek operations
2026-01-06 10:59:14 +08:00

1184 lines
61 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="#111827">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>没事Music</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: '#1db954',
dark: '#121212',
darker: '#000000',
light: '#b3b3b3',
surface: '#282828',
glass: 'rgba(255, 255, 255, 0.1)',
},
animation: {
'spin-slow': 'spin 8s linear infinite',
'slideUp': 'slideUp 0.3s ease-out',
'slideRight': 'slideRight 0.3s ease-out',
'fadeIn': 'fadeIn 0.3s ease-out',
'pulse-slow': 'pulse 4s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
keyframes: {
slideUp: {
'0%': { transform: 'translateY(100%)' },
'100%': { transform: 'translateY(0)' },
},
slideRight: {
'0%': { transform: 'translateX(-100%)' },
'100%': { transform: 'translateX(0)' },
},
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
}
},
backdropBlur: {
'xs': '2px',
}
}
}
}
</script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- React & ReactDOM -->
<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 -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<!-- PWA Manifest Injection -->
<link rel="manifest" id="my-manifest">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
.hide-scrollbar::-webkit-scrollbar { display: none; }
.hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
input[type="range"] {
-webkit-appearance: none;
background: transparent;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
height: 12px;
width: 12px;
border-radius: 50%;
background: #fff;
margin-top: -4px;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
}
input[type="range"]::-webkit-slider-runnable-track {
width: 100%;
height: 4px;
cursor: pointer;
background: rgba(255,255,255,0.2);
border-radius: 2px;
}
.lyric-line {
transition: all 0.3s ease;
opacity: 0.4;
transform: scale(0.95);
filter: blur(0.5px);
}
.lyric-line.active {
opacity: 1;
color: #1db954;
transform: scale(1.05);
font-weight: bold;
filter: blur(0);
text-shadow: 0 0 10px rgba(29, 185, 84, 0.3);
}
.glass-panel {
background: rgba(30, 30, 30, 0.6);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.text-shadow {
text-shadow: 0 2px 4px rgba(0,0,0,0.5);
}
/* Custom Select in Drawer */
.drawer-select {
background-color: rgba(255,255,255,0.1);
color: white;
border: 1px solid rgba(255,255,255,0.2);
padding: 8px 12px;
border-radius: 8px;
width: 100%;
outline: none;
appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 16px;
}
.drawer-select option {
background-color: #1f2937;
color: white;
}
</style>
</head>
<body class="bg-darker text-white h-screen flex flex-col overflow-hidden select-none">
<div id="root" class="h-full w-full"></div>
<script type="text/babel">
const { useState, useEffect, useRef, useMemo, useCallback } = React;
const API_BASE = "https://music-dl.sayqz.com";
const SOURCES = [
{ id: 'netease', name: '网易云' },
{ id: 'kuwo', name: '酷我' },
{ id: 'qq', name: 'QQ音乐' },
{ id: 'kugou', name: '酷狗' }
];
// --- Utility Functions ---
const formatTime = (seconds) => {
if (!seconds) return "0:00";
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
};
const parseLrc = (lrcText) => {
if (!lrcText) return [];
const lines = lrcText.split('\n');
const result = [];
const timeExp = /\[(\d{2}):(\d{2})\.(\d{2,3})\]/;
for (const line of lines) {
const match = timeExp.exec(line);
if (match) {
const min = parseInt(match[1]);
const sec = parseInt(match[2]);
const ms = parseInt(match[3]);
const time = min * 60 + sec + ms / (match[3].length === 3 ? 1000 : 100);
const text = line.replace(timeExp, '').trim();
if (text) {
result.push({ time, text });
}
}
}
return result;
};
// --- API Services ---
const api = {
search: async (keyword, source = 'netease', page = 1) => {
try {
const res = await fetch(`${API_BASE}/api/?type=search&keyword=${encodeURIComponent(keyword)}&source=${source}&page=${page}`);
const data = await res.json();
// Return full data object to get total and results
return data.code === 200 ? data.data : { results: [], total: 0 };
} catch (e) {
console.error("Search failed", e);
return { results: [], total: 0 };
}
},
getSongUrl: (id, source, br = '320k') => {
return `${API_BASE}/api/?source=${source}&id=${id}&type=url&br=${br}`;
},
getPicUrl: (id, source) => {
return `${API_BASE}/api/?source=${source}&id=${id}&type=pic`;
},
getLrc: async (id, source) => {
try {
const res = await fetch(`${API_BASE}/api/?source=${source}&id=${id}&type=lrc`);
const text = await res.text();
return text;
} catch (e) {
console.error("Get LRC failed", e);
return "";
}
},
getTopLists: async (source) => {
const predefinedLists = {
'netease': [
{ id: '3778678', name: '热歌榜', cover: 'https://p1.music.126.net/GhhuF6Ep5Tq9IEvLsyCN7w==/18708190348493229.jpg' },
{ id: '19723756', name: '飙升榜', cover: 'https://p1.music.126.net/DrRIg6CrgDfVLEph9SNh7w==/18696095720518497.jpg' },
{ id: '3779629', name: '新歌榜', cover: 'https://p1.music.126.net/N2HO5j8f9yVT9OIyZ7-gRA==/19068263547447148.jpg' },
{ id: '2884035', name: '原创榜', cover: 'https://p1.music.126.net/sBzD11nforcuh1jdLSgX7g==/18740076185638788.jpg' }
],
'qq': [
{ id: '26', name: '热歌榜', cover: 'https://y.gtimg.cn/music/photo_new/T003R300x300M000003z4Uoe1HTPH1.jpg' },
{ id: '4', name: '流行指数榜', cover: 'https://y.gtimg.cn/music/photo_new/T003R300x300M000000VjNKo0IG7zL.jpg' },
{ id: '27', name: '新歌榜', cover: 'https://y.gtimg.cn/music/photo_new/T003R300x300M000002AgqFp0r6taf.jpg' }
],
'kuwo': [
{ id: '93', name: '酷我飙升榜', cover: 'http://img1.kwcdn.kuwo.cn/star/upload/10/10/1485072610.jpg' },
{ id: '17', name: '酷我新歌榜', cover: 'http://img1.kwcdn.kuwo.cn/star/upload/11/11/1485072611.jpg' },
{ id: '16', name: '酷我热歌榜', cover: 'http://img1.kwcdn.kuwo.cn/star/upload/3/3/1485072603.jpg' }
],
'kugou': [
{ id: '8888', name: '酷狗TOP500', cover: 'http://imge.kugou.com/mcommon/400/20150331/20150331161102575467.png' },
{ id: '6666', name: '飙升榜', cover: 'http://imge.kugou.com/mcommon/400/20150331/20150331161100806437.png' }
]
};
return predefinedLists[source] || [];
},
getTopListSongs: async (id, source) => {
try {
const res = await fetch(`${API_BASE}/api/?source=${source}&id=${id}&type=toplist`);
const data = await res.json();
if (data.code === 200) {
return Array.isArray(data.data) ? data.data : (data.data.list || data.data.tracks || []);
}
return [];
} catch (e) {
console.error("Get Toplist Songs failed", e);
return [];
}
}
};
// --- Components ---
const Icon = ({ name, size = "", className = "", onClick }) => (
<i className={`fa-solid fa-${name} ${size} ${className} cursor-pointer hover:text-white transition-colors`} onClick={onClick}></i>
);
const Spinner = () => (
<div className="flex justify-center items-center p-4">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary"></div>
</div>
);
const ImageWithFallback = ({ src, alt, className, fallbackText }) => {
const [error, setError] = useState(false);
if (error) {
return (
<div className={`${className} bg-gradient-to-br from-gray-700 to-gray-900 flex items-center justify-center text-gray-400 font-bold text-xs p-2 text-center`}>
{fallbackText || alt}
</div>
);
}
return <img src={src} alt={alt} className={className} onError={() => setError(true)} loading="lazy" />;
};
const SongItem = ({ song, onClick, isPlaying, isCurrent, index, onDelete, onLike, isLiked }) => (
<div
onClick={onClick}
className={`group flex items-center p-3 border-b border-white/5 hover:bg-white/10 active:bg-white/20 transition-colors cursor-pointer ${isCurrent ? 'bg-white/10' : ''}`}
>
{index !== undefined && (
<div className={`w-8 text-center text-sm font-bold ${index < 3 ? 'text-primary' : 'text-gray-500'}`}>
{index + 1}
</div>
)}
<div className="w-12 h-12 rounded-lg bg-gray-800 flex-shrink-0 overflow-hidden relative ml-2 shadow-lg">
<ImageWithFallback
src={api.getPicUrl(song.id, song.platform || song.source)}
alt={song.name}
className="w-full h-full object-cover"
fallbackText={song.name}
/>
{isCurrent && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center backdrop-blur-xs">
<Icon name={isPlaying ? "chart-simple" : "pause"} className="text-primary" />
</div>
)}
</div>
<div className="ml-3 flex-1 min-w-0">
<div className={`text-sm font-medium truncate ${isCurrent ? 'text-primary' : 'text-white'}`}>
{song.name}
</div>
<div className="text-xs text-gray-400 truncate">
{song.artist} · {SOURCES.find(s => s.id === (song.platform || song.source))?.name || song.source}
</div>
</div>
<div className="ml-2 flex items-center gap-4 opacity-100 sm:opacity-0 group-hover:opacity-100 transition-opacity">
{onLike && (
<Icon
name="heart"
className={isLiked ? "text-red-500" : "text-gray-500 hover:text-red-500"}
onClick={(e) => { e.stopPropagation(); onLike(song); }}
/>
)}
{onDelete && (
<Icon
name="trash"
className="text-gray-500 hover:text-red-500"
onClick={(e) => { e.stopPropagation(); onDelete(song); }}
/>
)}
</div>
</div>
);
const SideDrawer = ({ isOpen, onClose, view, setView, quality, setQuality, onClearCache }) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm animate-[fadeIn_0.2s]" onClick={onClose}></div>
<div className="relative w-64 h-full bg-gray-900 shadow-2xl flex flex-col animate-[slideRight_0.3s_ease-out]">
<div className="p-6 border-b border-white/10 flex items-center gap-3">
<div className="w-10 h-10 bg-primary rounded-full flex items-center justify-center text-black font-bold text-xl">
<i className="fa-solid fa-music"></i>
</div>
<h1 className="text-xl font-bold tracking-tight">TuneHub</h1>
</div>
<div className="flex-1 py-4 overflow-y-auto">
<div className="px-4 mb-2 text-xs font-bold text-gray-500 uppercase tracking-wider">菜单</div>
<nav className="space-y-1 px-2">
<button
onClick={() => { setView('discover'); onClose(); }}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${view === 'discover' ? 'bg-white/10 text-primary' : 'text-gray-300 hover:bg-white/5 hover:text-white'}`}
>
<Icon name="compass" className="w-5 text-center" />
<span>发现音乐</span>
</button>
<button
onClick={() => { setView('favorites'); onClose(); }}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg transition-colors ${view === 'favorites' ? 'bg-white/10 text-primary' : 'text-gray-300 hover:bg-white/5 hover:text-white'}`}
>
<Icon name="heart" className="w-5 text-center" />
<span>我喜欢的</span>
</button>
</nav>
<div className="mt-8 px-4 mb-2 text-xs font-bold text-gray-500 uppercase tracking-wider">设置</div>
<div className="px-4 py-2">
<label className="block text-sm text-gray-300 mb-2">默认播放音质</label>
<select
value={quality}
onChange={(e) => setQuality(e.target.value)}
className="drawer-select"
>
<option value="128k">标准 (128k)</option>
<option value="320k">高品 (320k)</option>
<option value="flac">无损 (FLAC)</option>
</select>
</div>
<div className="px-4 py-2 mt-2">
<button
onClick={() => {
if(confirm('确定要清除所有缓存数据吗?这将重置播放列表和收藏。')) {
onClearCache();
onClose();
}
}}
className="w-full py-2 px-4 rounded-lg border border-red-500/50 text-red-500 hover:bg-red-500/10 transition-colors text-sm flex items-center justify-center gap-2"
>
<Icon name="trash-can" />
清理缓存数据
</button>
</div>
</div>
<div className="p-4 border-t border-white/10 text-xs text-gray-500 text-center">
<p>&copy; 2026 没想好 Music</p>
</div>
</div>
</div>
);
};
const MiniPlayer = ({ currentSong, isPlaying, togglePlay, onExpand, progress, duration }) => {
if (!currentSong) return null;
const progressPercent = duration ? (progress / duration) * 100 : 0;
return (
<div className="fixed bottom-0 left-0 right-0 glass-panel z-40 flex flex-col pb-safe animate-[slideUp_0.3s_ease-out]">
<div className="h-0.5 w-full bg-gray-700/50">
<div className="h-full bg-primary transition-all duration-300 shadow-[0_0_10px_rgba(29,185,84,0.5)]" style={{ width: `${progressPercent}%` }}></div>
</div>
<div className="flex items-center p-3" onClick={onExpand}>
<div className={`w-10 h-10 rounded-full overflow-hidden flex-shrink-0 border border-white/10 ${isPlaying ? 'animate-spin-slow' : ''}`}>
<ImageWithFallback
src={api.getPicUrl(currentSong.id, currentSong.platform || currentSong.source)}
className="w-full h-full object-cover"
/>
</div>
<div className="ml-3 flex-1 min-w-0">
<div className="text-sm font-medium text-white truncate">{currentSong.name}</div>
<div className="text-xs text-gray-400 truncate">{currentSong.artist}</div>
</div>
<div className="flex items-center gap-4 px-2">
<div onClick={(e) => { e.stopPropagation(); togglePlay(); }} className="w-10 h-10 flex items-center justify-center rounded-full border border-white/20 hover:bg-white/10 active:scale-95 transition-all">
<Icon name={isPlaying ? "pause" : "play"} />
</div>
<Icon name="list" onClick={(e) => { e.stopPropagation(); onExpand('playlist'); }} />
</div>
</div>
</div>
);
};
const FullPlayer = ({ currentSong, isPlaying, togglePlay, onClose, progress, duration, seek, prev, next, lyrics, mode, toggleMode, volume, setVolume, isLiked, toggleLike }) => {
const [showLyrics, setShowLyrics] = useState(false);
const lyricContainerRef = useRef(null);
useEffect(() => {
if (showLyrics && lyricContainerRef.current && lyrics.length > 0) {
const activeIndex = lyrics.findIndex((l, i) => l.time <= progress && (lyrics[i+1]?.time > progress || i === lyrics.length - 1));
if (activeIndex !== -1) {
const activeEl = lyricContainerRef.current.children[activeIndex];
if (activeEl) {
activeEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}
}, [progress, showLyrics, lyrics]);
if (!currentSong) return null;
const activeLyricIndex = lyrics.findIndex((l, i) => l.time <= progress && (lyrics[i+1]?.time > progress || i === lyrics.length - 1));
const modeIcon = mode === 'loop' ? 'repeat' : (mode === 'one' ? '1' : 'shuffle');
return (
<div className="fixed inset-0 bg-darker z-50 flex flex-col animate-[slideUp_0.3s_ease-out]">
{/* Dynamic Background */}
<div className="absolute inset-0 z-0 overflow-hidden">
<img
src={api.getPicUrl(currentSong.id, currentSong.platform || currentSong.source)}
className="w-full h-full object-cover blur-3xl opacity-40 scale-150 animate-pulse-slow"
/>
<div className="absolute inset-0 bg-gradient-to-b from-black/30 via-black/60 to-black/90"></div>
</div>
{/* Header */}
<div className="relative z-10 flex items-center justify-between p-4 pt-8">
<Icon name="chevron-down" size="text-xl" onClick={onClose} className="p-2" />
<div className="text-center">
<div className="text-xs text-gray-300 uppercase tracking-widest font-semibold">正在播放</div>
</div>
<Icon name="ellipsis" size="text-xl" className="p-2" />
</div>
{/* Content */}
<div className="relative z-10 flex-1 flex flex-col items-center justify-center p-6 overflow-hidden" onClick={() => setShowLyrics(!showLyrics)}>
{!showLyrics ? (
<div className="w-full aspect-square max-w-sm rounded-2xl overflow-hidden shadow-[0_20px_50px_rgba(0,0,0,0.5)] mb-8 relative animate-[fadeIn_0.5s] border border-white/10">
<ImageWithFallback
src={api.getPicUrl(currentSong.id, currentSong.platform || currentSong.source)}
className="w-full h-full object-cover"
/>
</div>
) : (
<div className="w-full h-full overflow-y-auto hide-scrollbar text-center py-4 mask-image-gradient" ref={lyricContainerRef}>
{lyrics.length > 0 ? lyrics.map((line, i) => (
<p
key={i}
className={`lyric-line py-3 text-lg transition-all duration-500 ${i === activeLyricIndex ? 'active' : 'text-gray-400'}`}
>
{line.text}
</p>
)) : (
<p className="text-gray-500 mt-20">暂无歌词</p>
)}
</div>
)}
<div className="w-full mb-2 px-4 flex justify-between items-center">
<div className="flex-1 min-w-0 mr-4">
<h2 className="text-2xl font-bold text-white truncate mb-1 text-shadow">{currentSong.name}</h2>
<p className="text-lg text-gray-300 truncate">{currentSong.artist}</p>
</div>
<Icon
name="heart"
size="text-2xl"
className={`${isLiked ? "text-red-500" : "text-white/50 hover:text-white"} transition-colors`}
onClick={(e) => { e.stopPropagation(); toggleLike(currentSong); }}
/>
</div>
</div>
{/* Controls */}
<div className="relative z-10 p-8 pb-12">
<div className="mb-6">
<input
type="range"
min="0"
max={duration || 100}
value={progress}
onChange={(e) => seek(Number(e.target.value))}
className="w-full h-1 bg-gray-600 rounded-lg appearance-none cursor-pointer accent-primary"
/>
<div className="flex justify-between text-xs text-gray-400 mt-2 font-mono font-medium">
<span>{formatTime(progress)}</span>
<span>{formatTime(duration)}</span>
</div>
</div>
<div className="flex items-center justify-between px-2 mb-8">
<Icon name={modeIcon} className={`text-gray-400 hover:text-white transition-colors ${mode !== 'loop' ? 'text-primary' : ''}`} onClick={toggleMode} />
<Icon name="backward-step" size="text-3xl" onClick={prev} className="hover:scale-110 transition-transform" />
<div
onClick={togglePlay}
className="w-20 h-20 rounded-full bg-primary flex items-center justify-center text-black hover:scale-105 active:scale-95 transition-all cursor-pointer shadow-[0_0_20px_rgba(29,185,84,0.4)]"
>
<Icon name={isPlaying ? "pause" : "play"} size="text-3xl" />
</div>
<Icon name="forward-step" size="text-3xl" onClick={next} className="hover:scale-110 transition-transform" />
<div className="relative group">
<Icon name="volume-high" className="text-gray-400 hover:text-white" />
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 w-8 h-24 bg-gray-800 rounded-lg hidden group-hover:flex items-center justify-center p-2">
<input
type="range"
min="0"
max="1"
step="0.1"
value={volume}
onChange={(e) => setVolume(Number(e.target.value))}
className="w-24 h-1 -rotate-90 origin-center accent-primary"
/>
</div>
</div>
</div>
</div>
</div>
);
};
const PlaylistDrawer = ({ playlist, currentSong, playSong, onClose, clearPlaylist, deleteSong }) => (
<div className="fixed inset-0 z-50 flex flex-col justify-end bg-black/60 backdrop-blur-sm animate-[fadeIn_0.2s]" onClick={onClose}>
<div className="bg-gray-900/90 backdrop-blur-xl rounded-t-2xl max-h-[70vh] flex flex-col border-t border-white/10" onClick={e => e.stopPropagation()}>
<div className="p-4 border-b border-white/10 flex justify-between items-center sticky top-0 z-10">
<h3 className="text-lg font-bold">当前播放 <span className="text-gray-500 text-sm font-normal">({playlist.length})</span></h3>
<div className="flex gap-4">
<Icon name="trash-can" onClick={clearPlaylist} className="text-gray-400 hover:text-red-500" />
<Icon name="xmark" onClick={onClose} className="text-gray-400 hover:text-white" />
</div>
</div>
<div className="overflow-y-auto flex-1 hide-scrollbar p-2">
{playlist.length === 0 ? (
<div className="p-12 text-center text-gray-500 flex flex-col items-center">
<Icon name="music" size="text-4xl" className="mb-4 opacity-30" />
<p>播放列表为空</p>
</div>
) : (
playlist.map((song, idx) => (
<SongItem
key={`${song.id}-${idx}`}
song={song}
isCurrent={currentSong?.id === song.id}
isPlaying={currentSong?.id === song.id}
onClick={() => playSong(song)}
onDelete={() => deleteSong(idx)}
/>
))
)}
</div>
</div>
</div>
);
const TopListPage = ({ source, onBack, onPlaySong, onLike, isLiked }) => {
const [lists, setLists] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedList, setSelectedList] = useState(null);
const [listSongs, setListSongs] = useState([]);
const [loadingSongs, setLoadingSongs] = useState(false);
useEffect(() => {
loadTopLists();
}, [source]);
const loadTopLists = async () => {
setLoading(true);
const data = await api.getTopLists(source);
setLists(Array.isArray(data) ? data : []);
setLoading(false);
};
const handleListClick = async (list) => {
setSelectedList(list);
setLoadingSongs(true);
const songs = await api.getTopListSongs(list.id, source);
setListSongs(songs);
setLoadingSongs(false);
};
if (selectedList) {
return (
<div className="flex flex-col h-full bg-darker animate-[slideUp_0.2s_ease-out]">
<div className="p-4 flex items-center gap-4 sticky top-0 bg-darker/80 backdrop-blur-md z-20 border-b border-white/5">
<Icon name="arrow-left" onClick={() => setSelectedList(null)} />
<h2 className="font-bold text-lg truncate">{selectedList.name}</h2>
</div>
<div className="flex-1 overflow-y-auto hide-scrollbar">
<div className="w-full h-64 relative mb-4 overflow-hidden">
<ImageWithFallback src={selectedList.cover || selectedList.pic} className="w-full h-full object-cover opacity-60 blur-sm scale-110" />
<div className="absolute inset-0 bg-gradient-to-t from-darker to-transparent"></div>
<div className="absolute bottom-4 left-4 right-4">
<div className="font-bold text-3xl shadow-black drop-shadow-lg mb-2">{selectedList.name}</div>
<div className="text-sm text-gray-300">更新于 {new Date().toLocaleDateString()}</div>
</div>
</div>
{loadingSongs ? <Spinner /> : (
<div className="pb-24 px-2">
{listSongs.map((song, idx) => (
<SongItem
key={song.id}
song={{...song, source: source}}
index={idx}
onClick={() => onPlaySong(listSongs.map(s => ({...s, source})), idx)}
onLike={onLike}
isLiked={isLiked(song)}
/>
))}
</div>
)}
</div>
</div>
);
}
return (
<div className="p-4 pb-24 animate-[fadeIn_0.3s]">
<h2 className="text-xl font-bold mb-4 flex items-center gap-2">
<Icon name="fire" className="text-red-500" />
排行榜 <span className="text-sm font-normal text-gray-500 ml-2">{SOURCES.find(s=>s.id===source)?.name}</span>
</h2>
{loading ? <Spinner /> : (
<div className="grid grid-cols-2 gap-4">
{Array.isArray(lists) && lists.map(list => (
<div key={list.id} className="group bg-surface rounded-xl overflow-hidden cursor-pointer hover:bg-gray-700 transition-all hover:-translate-y-1 shadow-lg" onClick={() => handleListClick(list)}>
<div className="aspect-square relative overflow-hidden">
<ImageWithFallback
src={list.cover || list.pic}
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
fallbackText={list.name}
/>
<div className="absolute inset-0 bg-black/20 group-hover:bg-black/40 transition-colors"></div>
<div className="absolute bottom-2 right-2 bg-primary/90 text-black px-3 py-1 rounded-full text-xs flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-all translate-y-2 group-hover:translate-y-0 font-bold">
<Icon name="play" size="text-xs" /> 播放
</div>
</div>
<div className="p-3">
<div className="text-sm font-bold truncate text-gray-100">{list.name}</div>
</div>
</div>
))}
</div>
)}
</div>
);
};
const App = () => {
// State
const [query, setQuery] = useState('');
const [source, setSource] = useState('netease');
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [view, setView] = useState('discover'); // discover, search, favorites
// Pagination State
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const observerTarget = useRef(null);
// Player State
const [playlist, setPlaylist] = useState(() => JSON.parse(localStorage.getItem('th_playlist')) || []);
const [currentSong, setCurrentSong] = useState(() => JSON.parse(localStorage.getItem('th_current')) || null);
const [isPlaying, setIsPlaying] = useState(false);
const [duration, setDuration] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const [lyrics, setLyrics] = useState([]);
const [mode, setMode] = useState('loop'); // loop, one, shuffle
const [volume, setVolume] = useState(1);
const [favorites, setFavorites] = useState(() => JSON.parse(localStorage.getItem('th_favorites')) || []);
const [quality, setQuality] = useState(() => localStorage.getItem('th_quality') || '320k');
// UI State
const [showFullPlayer, setShowFullPlayer] = useState(false);
const [showPlaylist, setShowPlaylist] = useState(false);
const [showSideDrawer, setShowSideDrawer] = useState(false);
const audioRef = useRef(new Audio());
// Media Session Refs
const playNextRef = useRef(null);
const playPrevRef = useRef(null);
const togglePlayRef = useRef(null);
const handleSeekRef = useRef(null);
// --- Effects ---
useEffect(() => { localStorage.setItem('th_playlist', JSON.stringify(playlist)); }, [playlist]);
useEffect(() => { localStorage.setItem('th_current', JSON.stringify(currentSong)); }, [currentSong]);
useEffect(() => { localStorage.setItem('th_favorites', JSON.stringify(favorites)); }, [favorites]);
useEffect(() => { localStorage.setItem('th_quality', quality); }, [quality]);
useEffect(() => {
if (currentSong) {
api.getLrc(currentSong.id, currentSong.platform || currentSong.source).then(lrc => {
setLyrics(parseLrc(lrc));
});
} else {
setLyrics([]);
}
}, [currentSong]);
useEffect(() => {
const audio = audioRef.current;
audio.volume = volume;
const updateTime = () => {
setCurrentTime(audio.currentTime);
};
const updateDuration = () => setDuration(audio.duration);
const onEnded = () => playNext(true);
audio.addEventListener('timeupdate', updateTime);
audio.addEventListener('loadedmetadata', updateDuration);
audio.addEventListener('ended', onEnded);
return () => {
audio.removeEventListener('timeupdate', updateTime);
audio.removeEventListener('loadedmetadata', updateDuration);
audio.removeEventListener('ended', onEnded);
};
}, [playlist, currentSong, mode]);
useEffect(() => {
if (currentSong) {
// Update URL when quality changes or song changes
const url = api.getSongUrl(currentSong.id, currentSong.platform || currentSong.source, quality);
// Only update src if it's different to avoid reloading same song on re-render (unless quality changed)
// Note: audioRef.current.src returns full absolute URL
const currentSrc = audioRef.current.src;
// Simple check if src changed significantly (avoiding minor encoding diffs if possible, but exact match is safer)
if (currentSrc !== url) {
const wasPlaying = isPlaying;
audioRef.current.src = url;
if (wasPlaying) {
audioRef.current.play()
.then(() => setIsPlaying(true))
.catch(e => {
console.warn("Auto-play prevented:", e);
setIsPlaying(false);
});
}
}
}
}, [currentSong, quality]); // Re-run when quality changes
useEffect(() => {
if (currentSong) {
if (isPlaying) audioRef.current.play().catch(() => setIsPlaying(false));
else audioRef.current.pause();
}
}, [isPlaying]);
// --- Handlers ---
const handleSearch = async (e) => {
e.preventDefault();
if (!query.trim()) return;
setView('search');
setLoading(true);
setResults([]);
setPage(1);
setHasMore(true);
const data = await api.search(query, source, 1);
setResults(data.results || []);
// If total is provided, use it; otherwise fallback to check if we got full page (20)
const limit = data.limit || 20;
setHasMore((data.results || []).length >= limit);
setLoading(false);
};
const loadMoreResults = useCallback(async () => {
if (loadingMore || !hasMore) return;
setLoadingMore(true);
const nextPage = page + 1;
const data = await api.search(query, source, nextPage);
if (data.results && data.results.length > 0) {
setResults(prev => [...prev, ...data.results]);
setPage(nextPage);
const limit = data.limit || 20;
setHasMore(data.results.length >= limit);
} else {
setHasMore(false);
}
setLoadingMore(false);
}, [page, query, source, loadingMore, hasMore]);
useEffect(() => {
const observer = new IntersectionObserver(
entries => {
if (entries[0].isIntersecting && hasMore && !loading && !loadingMore) {
loadMoreResults();
}
},
{ threshold: 0.1 }
);
if (view === 'search' && observerTarget.current) {
observer.observe(observerTarget.current);
}
return () => observer.disconnect();
}, [loadMoreResults, view, hasMore, loading, loadingMore]);
const playSongList = (songs, startIndex = 0) => {
setPlaylist(songs);
setCurrentSong(songs[startIndex]);
setIsPlaying(true);
};
const addToPlaylist = (song) => {
if (!playlist.find(s => s.id === song.id)) {
setPlaylist([...playlist, song]);
}
setCurrentSong(song);
setIsPlaying(true);
};
const togglePlay = () => setIsPlaying(!isPlaying);
const playNext = (auto = false) => {
if (playlist.length === 0) return;
if (auto && mode === 'one') {
audioRef.current.currentTime = 0;
audioRef.current.play();
return;
}
let nextIdx;
const currIdx = playlist.findIndex(s => s.id === currentSong?.id);
if (mode === 'shuffle') {
nextIdx = Math.floor(Math.random() * playlist.length);
} else {
nextIdx = (currIdx + 1) % playlist.length;
}
setCurrentSong(playlist[nextIdx]);
setIsPlaying(true);
};
const playPrev = () => {
if (playlist.length === 0) return;
const idx = playlist.findIndex(s => s.id === currentSong?.id);
const prevIdx = (idx - 1 + playlist.length) % playlist.length;
setCurrentSong(playlist[prevIdx]);
setIsPlaying(true);
};
const handleSeek = (time) => {
if (Number.isFinite(time)) {
audioRef.current.currentTime = time;
setCurrentTime(time);
updateMediaSessionPosition();
}
};
const deleteFromPlaylist = (index) => {
const newPlaylist = [...playlist];
newPlaylist.splice(index, 1);
setPlaylist(newPlaylist);
if (playlist[index].id === currentSong?.id) {
if (newPlaylist.length > 0) {
setCurrentSong(newPlaylist[index % newPlaylist.length]);
} else {
setCurrentSong(null);
setIsPlaying(false);
}
}
};
const toggleLike = (song) => {
const exists = favorites.find(s => s.id === song.id);
if (exists) {
setFavorites(favorites.filter(s => s.id !== song.id));
} else {
setFavorites([song, ...favorites]);
}
};
const isLiked = (song) => favorites.some(f => f.id === song.id);
const toggleMode = () => {
const modes = ['loop', 'one', 'shuffle'];
const next = modes[(modes.indexOf(mode) + 1) % modes.length];
setMode(next);
};
// --- Media Session Integration ---
const updateMediaSessionPosition = () => {
if ('mediaSession' in navigator && audioRef.current && !isNaN(audioRef.current.duration)) {
try {
navigator.mediaSession.setPositionState({
duration: audioRef.current.duration,
playbackRate: audioRef.current.playbackRate,
position: audioRef.current.currentTime
});
} catch (e) { console.error("MediaSession Position Error:", e); }
}
};
// Update refs for Media Session actions
useEffect(() => {
playNextRef.current = playNext;
playPrevRef.current = playPrev;
togglePlayRef.current = togglePlay;
handleSeekRef.current = handleSeek;
});
// Media Session Actions (One-time setup)
useEffect(() => {
if (!('mediaSession' in navigator)) return;
const actionHandlers = [
['play', () => togglePlayRef.current?.()],
['pause', () => togglePlayRef.current?.()],
['previoustrack', () => playPrevRef.current?.()],
['nexttrack', () => playNextRef.current?.()],
['seekto', (details) => handleSeekRef.current?.(details.seekTime)],
];
for (const [action, handler] of actionHandlers) {
try { navigator.mediaSession.setActionHandler(action, handler); } catch (e) {}
}
}, []);
// Media Session Metadata
useEffect(() => {
if (!('mediaSession' in navigator)) return;
if (currentSong) {
navigator.mediaSession.metadata = new MediaMetadata({
title: currentSong.name,
artist: currentSong.artist,
album: SOURCES.find(s => s.id === (currentSong.platform || currentSong.source))?.name || '',
artwork: [
{ src: api.getPicUrl(currentSong.id, currentSong.platform || currentSong.source), sizes: '512x512', type: 'image/jpeg' }
]
});
} else {
navigator.mediaSession.metadata = null;
}
}, [currentSong]);
// Media Session Playback State & Position
useEffect(() => {
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused';
updateMediaSessionPosition();
}
}, [isPlaying]);
// Update position when song loads
useEffect(() => {
const audio = audioRef.current;
const onLoadedMetadata = () => updateMediaSessionPosition();
audio.addEventListener('loadedmetadata', onLoadedMetadata);
return () => audio.removeEventListener('loadedmetadata', onLoadedMetadata);
}, []);
// --- Render ---
return (
<div className="flex flex-col h-full bg-darker font-sans">
<SideDrawer
isOpen={showSideDrawer}
onClose={() => setShowSideDrawer(false)}
view={view}
setView={setView}
quality={quality}
setQuality={setQuality}
onClearCache={handleClearCache}
/>
{/* Top Navigation / Search Bar */}
<div className="p-4 bg-darker/90 backdrop-blur-md sticky top-0 z-30 border-b border-white/5">
<form onSubmit={handleSearch} className="flex gap-3 mb-3 items-center">
<Icon name="bars" size="text-xl" onClick={() => setShowSideDrawer(true)} className="text-gray-300 p-1" />
<div className="relative flex-1 group">
<Icon name="search" className="absolute left-3 top-3 text-gray-400 group-focus-within:text-primary transition-colors" />
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="搜索歌曲、歌手..."
className="w-full bg-gray-800 text-white pl-10 pr-4 py-2 rounded-full focus:outline-none focus:ring-2 focus:ring-primary/50 transition-all"
/>
</div>
{view !== 'discover' && <button type="button" onClick={() => {setView('discover'); setQuery('');}} className="text-gray-400 px-2 hover:text-white text-sm">取消</button>}
</form>
<div className="flex gap-2 overflow-x-auto hide-scrollbar pb-1 pl-1">
{SOURCES.map(s => (
<button
key={s.id}
onClick={() => { setSource(s.id); if(view==='search') handleSearch({preventDefault:()=>{}}); }}
className={`px-3 py-1 rounded-full text-xs whitespace-nowrap border transition-all ${source === s.id ? 'bg-white text-black border-white font-bold' : 'text-gray-400 border-gray-700 hover:border-gray-500'}`}
>
{s.name}
</button>
))}
</div>
</div>
{/* Main Content Area */}
<div className="flex-1 overflow-y-auto hide-scrollbar relative">
{view === 'search' ? (
loading && results.length === 0 ? (
<Spinner />
) : results.length > 0 ? (
<div className="pb-24 animate-[fadeIn_0.3s]">
<div className="px-4 py-2 text-xs text-gray-500 uppercase tracking-wider font-bold">搜索结果</div>
{results.map((song, index) => (
<SongItem
key={`${song.id}-${index}`}
song={song}
onClick={() => addToPlaylist({...song, source: song.platform || source})}
isCurrent={currentSong?.id === song.id}
onLike={toggleLike}
isLiked={isLiked(song)}
/>
))}
{/* Sentinel Element for Infinite Scroll */}
<div ref={observerTarget} className="h-10 flex justify-center items-center py-4">
{loadingMore && <div className="animate-spin rounded-full h-5 w-5 border-t-2 border-b-2 border-primary"></div>}
{!hasMore && results.length > 0 && <span className="text-xs text-gray-600">没有更多了</span>}
</div>
</div>
) : (
<div className="flex flex-col items-center justify-center h-64 text-gray-500">
<Icon name="magnifying-glass" size="text-4xl" className="mb-4 opacity-30" />
<p>未找到相关歌曲</p>
</div>
)
) : view === 'favorites' ? (
<div className="animate-[fadeIn_0.3s]">
<div className="p-4 flex items-center gap-4 bg-gradient-to-b from-red-900/20 to-transparent">
<div className="w-24 h-24 bg-gradient-to-br from-red-500 to-pink-600 rounded-xl flex items-center justify-center shadow-lg">
<Icon name="heart" size="text-4xl" className="text-white" />
</div>
<div>
<h2 className="text-2xl font-bold">我喜欢的音乐</h2>
<p className="text-gray-400 text-sm">{favorites.length} 首歌曲</p>
</div>
</div>
<div className="pb-24">
{favorites.length > 0 ? favorites.map((song, idx) => (
<SongItem
key={song.id}
song={song}
index={idx}
onClick={() => playSongList(favorites, idx)}
isCurrent={currentSong?.id === song.id}
onLike={toggleLike}
isLiked={true}
/>
)) : (
<div className="p-8 text-center text-gray-500">
<p>还没有收藏歌曲</p>
<p className="text-xs mt-2">点击心形图标收藏你喜欢的音乐</p>
</div>
)}
</div>
</div>
) : (
<TopListPage
source={source}
onPlaySong={playSongList}
onLike={toggleLike}
isLiked={isLiked}
/>
)}
</div>
{/* Mini Player */}
<MiniPlayer
currentSong={currentSong}
isPlaying={isPlaying}
togglePlay={togglePlay}
onExpand={(type) => type === 'playlist' ? setShowPlaylist(true) : setShowFullPlayer(true)}
progress={currentTime}
duration={duration}
/>
{/* Overlays */}
{showFullPlayer && (
<FullPlayer
currentSong={currentSong}
isPlaying={isPlaying}
togglePlay={togglePlay}
onClose={() => setShowFullPlayer(false)}
progress={currentTime}
duration={duration}
seek={handleSeek}
prev={playPrev}
next={playNext}
lyrics={lyrics}
mode={mode}
toggleMode={toggleMode}
volume={volume}
setVolume={setVolume}
isLiked={isLiked(currentSong)}
toggleLike={toggleLike}
/>
)}
{showPlaylist && (
<PlaylistDrawer
playlist={playlist}
currentSong={currentSong}
playSong={(song) => { setCurrentSong(song); setIsPlaying(true); }}
onClose={() => setShowPlaylist(false)}
clearPlaylist={() => { setPlaylist([]); setCurrentSong(null); setIsPlaying(false); }}
deleteSong={deleteFromPlaylist}
/>
)}
</div>
);
};
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
</script>
<!-- PWA Manifest Generation Script -->
<script>
const manifest = {
"name": "TuneHub Music",
"short_name": "TuneHub",
"start_url": ".",
"display": "standalone",
"background_color": "#111827",
"theme_color": "#111827",
"orientation": "portrait",
"icons": [
{
"src": "https://img.icons8.com/fluency/512/musical-notes.png",
"sizes": "512x512",
"type": "image/png"
}
]
};
const stringManifest = JSON.stringify(manifest);
const blob = new Blob([stringManifest], {type: 'application/json'});
const manifestURL = URL.createObjectURL(blob);
document.getElementById('my-manifest').setAttribute('href', manifestURL);
</script>
</body>
</html>