Files
Mymusic3/index.html
2026-01-06 14:12:58 +08:00

1337 lines
67 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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";
// Use relative path for sync service, assuming Nginx proxy is configured to forward /api/kv to the sync service
const SYNC_API_BASE = "/api";
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' }
]
};
try {
const res = await fetch(`${API_BASE}/api/?source=${source}&type=toplists`);
const data = await res.json();
if (data.code === 200) {
// 兼容多种返回结构data本身是数组或者data.list是数组
const list = Array.isArray(data.data) ? data.data : (data.data?.list || data.data?.topList || []);
if (Array.isArray(list) && list.length > 0) {
return list;
}
}
} catch (e) {
console.warn("Failed to fetch remote toplists, falling back to local list", e);
}
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 [];
}
}
};
// --- Sync Service ---
const syncService = {
get: async (key, token) => {
if (!token) return null;
try {
const res = await fetch(`${SYNC_API_BASE}/kv/${key}?token=${encodeURIComponent(token)}`);
if (res.ok) {
return await res.json();
}
} catch (e) {
console.error("Sync get failed", e);
}
return null;
},
set: async (key, data, token) => {
if (!token) return;
try {
await fetch(`${SYNC_API_BASE}/kv/${key}?token=${encodeURIComponent(token)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
} catch (e) {
console.error("Sync set failed", e);
}
}
};
// --- 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} {song.album ? `· ${song.album}` : ''} · {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, syncToken, setSyncToken, onSyncNow }) => {
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">Meishi Music</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">
<label className="block text-sm text-gray-300 mb-2">云同步密钥</label>
<div className="flex gap-2">
<input
type="text"
value={syncToken}
onChange={(e) => setSyncToken(e.target.value)}
placeholder="输入任意密钥以同步"
className="bg-white/10 border border-white/20 rounded-lg px-3 py-2 text-sm text-white w-full focus:outline-none focus:border-primary"
/>
<button
onClick={onSyncNow}
className="bg-primary text-black px-3 py-2 rounded-lg text-sm font-bold whitespace-nowrap"
>
同步
</button>
</div>
<p className="text-xs text-gray-500 mt-1">使用相同的密钥在多端同步收藏列表</p>
</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} {currentSong.album ? `· ${currentSong.album}` : ''}
</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} {currentSong.album ? `· ${currentSong.album}` : ''}
</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 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 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');
const [syncToken, setSyncToken] = useState(() => localStorage.getItem('th_sync_token') || '');
// 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));
// Auto sync to cloud if token exists
if (syncToken && favorites.length > 0) {
// Debounce could be added here, but for simplicity we just sync
syncService.set('favorites', favorites, syncToken);
}
}, [favorites, syncToken]);
useEffect(() => { localStorage.setItem('th_quality', quality); }, [quality]);
useEffect(() => { localStorage.setItem('th_sync_token', syncToken); }, [syncToken]);
// Initial Sync
useEffect(() => {
if (syncToken) {
handleSync();
}
}, []); // Run once on mount if token exists
const handleSync = async () => {
if (!syncToken) return;
const cloudFavorites = await syncService.get('favorites', syncToken);
if (cloudFavorites && Array.isArray(cloudFavorites)) {
// Merge strategy: Combine unique songs by ID
setFavorites(prev => {
const combined = [...prev];
cloudFavorites.forEach(cloudSong => {
if (!combined.find(s => s.id === cloudSong.id)) {
combined.push(cloudSong);
}
});
return combined;
});
}
};
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 updateMediaSessionPosition = () => {
if (!('mediaSession' in navigator)) return;
const audio = audioRef.current;
if (!audio) return;
const duration = Number.isFinite(audio.duration) && audio.duration > 0 ? audio.duration : 0;
const position = Number.isFinite(audio.currentTime) ? audio.currentTime : 0;
const playbackRate = Number.isFinite(audio.playbackRate) ? audio.playbackRate : 1;
try {
navigator.mediaSession.setPositionState({
duration: duration,
playbackRate: playbackRate,
position: duration > 0 ? Math.min(position, duration) : position
});
} catch (e) {}
};
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 ---
// 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' }
]
});
// Reset position state when song changes
try { navigator.mediaSession.setPositionState({ duration: 0, playbackRate: 1, position: 0 }); } catch(e) {}
} else {
navigator.mediaSession.metadata = null;
try { navigator.mediaSession.setPositionState({ duration: 0, playbackRate: 1, position: 0 }); } catch(e) {}
}
}, [currentSong]);
// Media Session Logic (Unified)
useEffect(() => {
if (!('mediaSession' in navigator)) return;
const audio = audioRef.current;
const updateState = () => {
if (!audio) return;
// Update Playback State directly from audio element source of truth
try {
navigator.mediaSession.playbackState = audio.paused ? 'paused' : 'playing';
} catch(e) {}
updateMediaSessionPosition();
};
let lastPositionUpdate = 0;
const updatePositionThrottled = () => {
const now = Date.now();
if (now - lastPositionUpdate < 500) return;
lastPositionUpdate = now;
updateMediaSessionPosition();
};
const eventHandlers = [
['play', updateState],
['pause', updateState],
['playing', updateState], // Important: triggered when playback actually starts/resumes
['waiting', updateState],
['seeking', updateState],
['seeked', updateState],
['ratechange', updateState],
['durationchange', updateState],
['loadedmetadata', updateState],
['ended', updateState],
['timeupdate', updatePositionThrottled]
];
eventHandlers.forEach(([evt, handler]) => audio.addEventListener(evt, handler));
return () => {
eventHandlers.forEach(([evt, handler]) => audio.removeEventListener(evt, handler));
};
}, []);
const handleClearCache = () => {
localStorage.removeItem('th_playlist');
localStorage.removeItem('th_current');
localStorage.removeItem('th_favorites');
localStorage.removeItem('th_quality');
localStorage.removeItem('th_sync_token');
window.location.reload();
};
// --- 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}
syncToken={syncToken}
setSyncToken={setSyncToken}
onSyncNow={handleSync}
/>
{/* 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": "Meishi Music",
"short_name": "MeiShi",
"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>