Add a new sync service and frontend integration to allow syncing favorites across devices using a token. - Configure `sync-service` in docker-compose.yml on port 7482 - Add sync token input and manual sync button to SideDrawer - Implement auto-sync logic to persist favorites to the KV store - Add logic to merge cloud favorites with local data on initialization
1268 lines
65 KiB
HTML
1268 lines
65 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' }
|
|
]
|
|
};
|
|
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} · {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">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">
|
|
<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}</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');
|
|
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 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> |