1337 lines
67 KiB
HTML
1337 lines
67 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";
|
||
// 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>© 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> |