1804 lines
90 KiB
HTML
1804 lines
90 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;
|
||
|
||
// Use relative path for API proxy to avoid CORS issues
|
||
// Nginx forwards /api/ to sync-server, which proxies /music-api to music-dl.sayqz.com
|
||
const API_BASE = "/api/music-api";
|
||
// 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: '酷狗' }
|
||
];
|
||
const IS_IOS = (() => {
|
||
if (typeof navigator === 'undefined') return false;
|
||
const ua = navigator.userAgent || '';
|
||
const platform = navigator.platform || '';
|
||
const iOSUA = /iPad|iPhone|iPod/.test(ua);
|
||
const iPadOS = platform === 'MacIntel' && navigator.maxTouchPoints > 1;
|
||
return iOSUA || iPadOS;
|
||
})();
|
||
|
||
// --- Utility Functions ---
|
||
const formatTime = (seconds) => {
|
||
// 增加保护:处理负数、Infinity、NaN等异常值
|
||
if (!Number.isFinite(seconds) || seconds < 0) 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;
|
||
};
|
||
|
||
const normalizeDurationSeconds = (value) => {
|
||
if (value === null || value === undefined) return 0;
|
||
if (typeof value === 'number') {
|
||
if (!Number.isFinite(value) || value <= 0) return 0;
|
||
return value >= 1000 ? value / 1000 : value;
|
||
}
|
||
if (typeof value === 'string') {
|
||
const trimmed = value.trim();
|
||
if (!trimmed) return 0;
|
||
const numeric = Number(trimmed);
|
||
if (!Number.isNaN(numeric)) {
|
||
return normalizeDurationSeconds(numeric);
|
||
}
|
||
const parts = trimmed.split(':').map(part => Number(part));
|
||
if (parts.some(part => Number.isNaN(part))) return 0;
|
||
if (parts.length === 2) return parts[0] * 60 + parts[1];
|
||
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||
}
|
||
return 0;
|
||
};
|
||
|
||
const getSongDurationSeconds = (song) => {
|
||
if (!song) return 0;
|
||
const candidates = [
|
||
song.duration,
|
||
song.dt,
|
||
song.time,
|
||
song.interval,
|
||
song.length,
|
||
song.playTime
|
||
];
|
||
for (const candidate of candidates) {
|
||
const seconds = normalizeDurationSeconds(candidate);
|
||
if (seconds > 0) return seconds;
|
||
}
|
||
return 0;
|
||
};
|
||
|
||
const resolveDurationSeconds = (audio, song) => {
|
||
if (audio) {
|
||
const audioDuration = Number.isFinite(audio.duration) && audio.duration > 0 ? audio.duration : 0;
|
||
if (audioDuration > 0) return audioDuration;
|
||
if (audio.seekable && audio.seekable.length) {
|
||
try {
|
||
const end = audio.seekable.end(audio.seekable.length - 1);
|
||
// 增加负数检查:iOS锁屏时可能返回负数
|
||
if (Number.isFinite(end) && end > 0) return end;
|
||
} catch (e) {}
|
||
}
|
||
}
|
||
const fallbackDuration = getSongDurationSeconds(song);
|
||
// 再次确保不会返回负数或异常值
|
||
return Number.isFinite(fallbackDuration) && fallbackDuration > 0 ? fallbackDuration : 0;
|
||
};
|
||
|
||
// --- ID Normalization ---
|
||
const normalizeSongId = (song) => {
|
||
if (!song || song.id === undefined || song.id === null) return song;
|
||
const id = String(song.id);
|
||
return song.id === id ? song : { ...song, id };
|
||
};
|
||
|
||
const normalizeSongList = (songs) => Array.isArray(songs) ? songs.map(normalizeSongId) : [];
|
||
|
||
// --- API Services ---
|
||
const api = {
|
||
search: async (keyword, source = 'netease', page = 1) => {
|
||
try {
|
||
const res = await fetch(`${API_BASE}?type=search&keyword=${encodeURIComponent(keyword)}&source=${source}&page=${page}`);
|
||
const data = await res.json();
|
||
if (data.code === 200) {
|
||
const payload = data.data || {};
|
||
return { ...payload, results: normalizeSongList(payload.results) };
|
||
}
|
||
return { results: [], total: 0 };
|
||
} catch (e) {
|
||
console.error("Search failed", e);
|
||
return { results: [], total: 0 };
|
||
}
|
||
},
|
||
getSongUrl: (id, source, br = '320k') => {
|
||
return `${API_BASE}?source=${source}&id=${id}&type=url&br=${br}`;
|
||
},
|
||
getPicUrl: (id, source) => {
|
||
return `${API_BASE}?source=${source}&id=${id}&type=pic`;
|
||
},
|
||
getLrc: async (id, source) => {
|
||
try {
|
||
const res = await fetch(`${API_BASE}?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}/?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}/?source=${source}&id=${id}&type=toplist`);
|
||
const data = await res.json();
|
||
if (data.code === 200) {
|
||
const list = Array.isArray(data.data) ? data.data : (data.data.list || data.data.tracks || []);
|
||
return normalizeSongList(list);
|
||
}
|
||
return [];
|
||
} catch (e) {
|
||
console.error("Get Toplist Songs failed", e);
|
||
return [];
|
||
}
|
||
},
|
||
getPlaylist: async (id, source = 'netease') => {
|
||
try {
|
||
const res = await fetch(`${API_BASE}/?type=playlist&id=${id}&source=${source}`);
|
||
const data = await res.json();
|
||
if (data.code === 200 && data.data && Array.isArray(data.data.list)) {
|
||
return normalizeSongList(data.data.list);
|
||
}
|
||
return [];
|
||
} catch (e) {
|
||
console.error("Get playlist 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, onImportNetease, syncMode }) => {
|
||
const [isSyncing, setIsSyncing] = useState(false);
|
||
const [syncMsg, setSyncMsg] = useState('');
|
||
|
||
const handleSyncClick = async () => {
|
||
if (!syncToken || isSyncing) return;
|
||
setIsSyncing(true);
|
||
setSyncMsg('');
|
||
try {
|
||
await onSyncNow();
|
||
setSyncMsg('同步完成');
|
||
setTimeout(() => setSyncMsg(''), 3000);
|
||
} catch (e) {
|
||
setSyncMsg('同步失败');
|
||
} finally {
|
||
setIsSyncing(false);
|
||
}
|
||
};
|
||
|
||
const handleNeteaseImportClick = () => {
|
||
const url = prompt("请输入网易云歌单分享链接\n(例如: https://music.163.com/playlist?id=...)");
|
||
if (url) {
|
||
const match = url.match(/[?&]id=(\d+)/);
|
||
if (match && match[1]) {
|
||
onImportNetease(match[1]);
|
||
onClose();
|
||
} else {
|
||
alert("无法识别歌单ID,请确保链接包含 id=数字");
|
||
}
|
||
}
|
||
};
|
||
|
||
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">
|
||
{syncMode === 'netease_playlist' ? '网易云歌单ID (自动同步)' : '云同步密钥'}
|
||
</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={handleSyncClick}
|
||
disabled={isSyncing}
|
||
className="bg-primary text-black px-3 py-2 rounded-lg text-sm font-bold whitespace-nowrap flex items-center gap-2 disabled:opacity-50"
|
||
>
|
||
<Icon name="rotate" className={isSyncing ? "animate-spin" : ""} />
|
||
{isSyncing ? '同步中' : '同步'}
|
||
</button>
|
||
</div>
|
||
<div className="flex justify-between items-center mt-1">
|
||
<p className="text-xs text-gray-500">
|
||
{syncMode === 'netease_playlist'
|
||
? '已关联歌单,将合并新歌并自动备份至云端'
|
||
: '使用相同的密钥在多端同步收藏列表'}
|
||
</p>
|
||
{syncMsg && <span className="text-xs text-primary font-bold animate-[fadeIn_0.3s]">{syncMsg}</span>}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="px-4 py-2">
|
||
<button
|
||
onClick={handleNeteaseImportClick}
|
||
className="w-full py-2 px-4 rounded-lg bg-red-600/20 border border-red-500/50 text-red-500 hover:bg-red-600/30 transition-colors text-sm flex items-center justify-center gap-2"
|
||
>
|
||
<Icon name="cloud-arrow-down" />
|
||
导入网易云歌单
|
||
</button>
|
||
<p className="text-xs text-gray-500 mt-1 px-1">导入后将设置ID为同步密钥,并定期获取新歌</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();
|
||
// Ensure we pass a clean object copy with normalized source to trigger updates correctly
|
||
toggleLike({
|
||
...currentSong,
|
||
source: currentSong.platform || currentSong.source
|
||
});
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Controls */}
|
||
<div className="relative z-10 p-6 pb-8">
|
||
<div className="mb-4">
|
||
<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-4">
|
||
<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, mode, toggleMode }) => {
|
||
const getModeText = () => {
|
||
switch(mode) {
|
||
case 'one': return '单曲循环';
|
||
case 'shuffle': return '随机播放';
|
||
default: return '列表循环';
|
||
}
|
||
};
|
||
|
||
return (
|
||
<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">
|
||
<div className="flex items-center gap-3">
|
||
<h3 className="text-lg font-bold">当前播放 <span className="text-gray-500 text-sm font-normal">({playlist.length})</span></h3>
|
||
{playlist.length > 0 && (
|
||
<button
|
||
onClick={toggleMode}
|
||
className="flex items-center gap-1.5 px-2 py-1 rounded-full bg-white/10 hover:bg-white/20 text-xs font-medium text-gray-300 hover:text-white transition-colors"
|
||
>
|
||
<Icon name={mode === 'one' ? '1' : (mode === 'shuffle' ? 'shuffle' : 'repeat')} size="text-xs" />
|
||
<span>{getModeText()}</span>
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="flex gap-4 items-center">
|
||
<Icon name="trash-can" onClick={clearPlaylist} className="text-gray-400 hover:text-red-500 transition-colors" />
|
||
<Icon name="xmark" onClick={onClose} className="text-gray-400 hover:text-white transition-colors" />
|
||
</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="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 flex flex-col gap-2">
|
||
{/* 返回按钮放置在标题上方,安全区域 */}
|
||
<div onClick={() => setSelectedList(null)} className="self-start px-4 py-2 bg-white/20 backdrop-blur-md rounded-full text-sm font-bold flex items-center gap-2 cursor-pointer hover:bg-white/30 transition-colors mb-2 shadow-lg border border-white/10 active:scale-95">
|
||
<Icon name="arrow-left" />
|
||
<span>返回列表</span>
|
||
</div>
|
||
<div className="font-bold text-3xl shadow-black drop-shadow-lg leading-tight">{selectedList.name}</div>
|
||
<div className="text-sm text-gray-300">更新于 {new Date().toLocaleDateString()}</div>
|
||
</div>
|
||
</div>
|
||
{loadingSongs ? <Spinner /> : (
|
||
<div className="pb-32 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-32 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(() => normalizeSongList(JSON.parse(localStorage.getItem('th_playlist')) || []));
|
||
const [currentSong, setCurrentSong] = useState(() => {
|
||
const cached = JSON.parse(localStorage.getItem('th_current'));
|
||
return cached ? normalizeSongId(cached) : 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(() => normalizeSongList(JSON.parse(localStorage.getItem('th_favorites')) || []));
|
||
// Add lastSyncedFavorites to track the state of favorites at the last successful sync
|
||
// This allows us to determine what the user actually changed (added or removed)
|
||
const [lastSyncedFavorites, setLastSyncedFavorites] = useState(() => normalizeSongList(JSON.parse(localStorage.getItem('th_favorites_synced')) || []));
|
||
const [quality, setQuality] = useState(() => localStorage.getItem('th_quality') || '320k');
|
||
const [syncToken, setSyncToken] = useState(() => localStorage.getItem('th_sync_token') || '');
|
||
const [lastSuccessToken, setLastSuccessToken] = useState(() => localStorage.getItem('th_last_success_token') || '');
|
||
const [syncMode, setSyncMode] = useState(() => localStorage.getItem('th_sync_mode') || 'server'); // 'server' | 'netease_playlist'
|
||
|
||
// UI State
|
||
const [showFullPlayer, setShowFullPlayer] = useState(false);
|
||
const [showPlaylist, setShowPlaylist] = useState(false);
|
||
const [showSideDrawer, setShowSideDrawer] = useState(false);
|
||
|
||
const audioRef = useRef(null);
|
||
const autoAdvanceLockRef = useRef(false);
|
||
const autoNextPendingRef = useRef(false);
|
||
const currentSongRef = useRef(null);
|
||
|
||
useEffect(() => {
|
||
currentSongRef.current = currentSong;
|
||
}, [currentSong]);
|
||
|
||
useEffect(() => {
|
||
autoAdvanceLockRef.current = false;
|
||
}, [currentSong]);
|
||
|
||
const playAudioWithFallback = (audio, options = {}) => {
|
||
if (!audio) return;
|
||
const { deferOnIOS = false } = options;
|
||
const isHidden = typeof document !== 'undefined' && document.hidden;
|
||
const shouldDefer = deferOnIOS && IS_IOS && !isHidden;
|
||
const doPlay = () => {
|
||
const playPromise = audio.play();
|
||
if (playPromise && typeof playPromise.catch === 'function') {
|
||
playPromise.catch(e => {
|
||
console.warn("Auto-play prevented:", e);
|
||
if (e && e.name === 'AbortError') return;
|
||
setIsPlaying(false);
|
||
});
|
||
}
|
||
};
|
||
|
||
if (shouldDefer) {
|
||
const onCanPlay = () => {
|
||
audio.removeEventListener('canplay', onCanPlay);
|
||
doPlay();
|
||
};
|
||
audio.addEventListener('canplay', onCanPlay);
|
||
try { audio.load(); } catch (e) {}
|
||
return;
|
||
}
|
||
|
||
doPlay();
|
||
};
|
||
|
||
// Media Session Refs
|
||
const playNextRef = useRef(null);
|
||
const playPrevRef = useRef(null);
|
||
const togglePlayRef = useRef(null);
|
||
const handleSeekRef = useRef(null);
|
||
|
||
// --- Effects ---
|
||
|
||
useEffect(() => { localStorage.setItem('th_playlist', JSON.stringify(playlist)); }, [playlist]);
|
||
useEffect(() => { localStorage.setItem('th_current', JSON.stringify(currentSong)); }, [currentSong]);
|
||
useEffect(() => { localStorage.setItem('th_favorites', JSON.stringify(favorites)); }, [favorites]);
|
||
useEffect(() => { localStorage.setItem('th_favorites_synced', JSON.stringify(lastSyncedFavorites)); }, [lastSyncedFavorites]);
|
||
useEffect(() => { localStorage.setItem('th_quality', quality); }, [quality]);
|
||
useEffect(() => { localStorage.setItem('th_sync_token', syncToken); }, [syncToken]);
|
||
useEffect(() => { localStorage.setItem('th_last_success_token', lastSuccessToken); }, [lastSuccessToken]);
|
||
useEffect(() => { localStorage.setItem('th_sync_mode', syncMode); }, [syncMode]);
|
||
|
||
// Auto Sync Logic for Private Server
|
||
useEffect(() => {
|
||
// Only auto-sync if we have a token, it matches the last successfully synced token,
|
||
// and there are actual changes compared to last sync.
|
||
if (syncToken && syncToken === lastSuccessToken && JSON.stringify(favorites) !== JSON.stringify(lastSyncedFavorites)) {
|
||
const timer = setTimeout(() => {
|
||
autoSyncFavorites();
|
||
}, 1000);
|
||
return () => clearTimeout(timer);
|
||
}
|
||
}, [favorites, syncToken, lastSuccessToken, lastSyncedFavorites]);
|
||
|
||
// Auto Sync Logic for Netease Playlist (Interval based)
|
||
useEffect(() => {
|
||
if (syncMode === 'netease_playlist' && syncToken) {
|
||
const doSync = async () => {
|
||
console.log("Auto syncing netease playlist...");
|
||
try {
|
||
const songs = await api.getPlaylist(syncToken);
|
||
if (songs && songs.length > 0) {
|
||
let hasChanges = false;
|
||
setFavorites(prev => {
|
||
const newFavs = [...prev];
|
||
// Add new songs (reverse to keep latest at top if we prepend)
|
||
[...songs].reverse().forEach(song => {
|
||
if (!newFavs.find(s => s.id === song.id)) {
|
||
newFavs.unshift({ ...song, source: 'netease' });
|
||
hasChanges = true;
|
||
}
|
||
});
|
||
return hasChanges ? newFavs : prev;
|
||
});
|
||
}
|
||
} catch (e) {
|
||
console.error("Auto sync netease playlist failed", e);
|
||
}
|
||
};
|
||
|
||
// Initial sync on mount is skipped because import handles it,
|
||
// or if page refresh, server sync will pull first.
|
||
// But if we want to ensure we get new songs from netease on page load:
|
||
doSync();
|
||
|
||
// Interval sync (15 minutes)
|
||
const interval = setInterval(doSync, 15 * 60 * 1000);
|
||
return () => clearInterval(interval);
|
||
}
|
||
}, [syncMode, syncToken]);
|
||
|
||
// 1. Auto Sync: Incremental update based on diff (Server Mode)
|
||
const autoSyncFavorites = async () => {
|
||
if (!syncToken || syncToken !== lastSuccessToken) return;
|
||
try {
|
||
// Get latest remote state
|
||
const remoteFavorites = normalizeSongList(await syncService.get('favorites', syncToken) || []);
|
||
|
||
// Calculate Diff: What did the user do locally since last sync?
|
||
// Added: In Local but not in LastSynced
|
||
const added = favorites.filter(f => !lastSyncedFavorites.find(ls => ls.id === f.id));
|
||
// Removed: In LastSynced but not in Local
|
||
const removedIds = lastSyncedFavorites.filter(ls => !favorites.find(f => f.id === ls.id)).map(s => s.id);
|
||
|
||
// Apply Diff to Remote
|
||
let newRemote = [...remoteFavorites];
|
||
|
||
// Remove deleted songs
|
||
if (removedIds.length > 0) {
|
||
newRemote = newRemote.filter(r => !removedIds.includes(r.id));
|
||
}
|
||
|
||
// Add new songs (avoid duplicates)
|
||
added.forEach(song => {
|
||
if (!newRemote.find(r => r.id === song.id)) {
|
||
newRemote.push(song);
|
||
}
|
||
});
|
||
|
||
// Push to server
|
||
await syncService.set('favorites', newRemote, syncToken);
|
||
|
||
// Update states
|
||
// We update LastSynced to match the new state
|
||
setLastSyncedFavorites(newRemote);
|
||
// We also update Favorites to match NewRemote (in case Remote had other changes we just pulled)
|
||
setFavorites(newRemote);
|
||
|
||
} catch (e) {
|
||
console.error("Auto sync failed", e);
|
||
}
|
||
};
|
||
|
||
// 2. Manual Sync (Unified Handler)
|
||
const manualSyncFavorites = async () => {
|
||
if (!syncToken) return;
|
||
|
||
// Server Mode Sync
|
||
|
||
// Determine Mode: Switch (Overwrite Local) or Sync (Merge)
|
||
const isSwitchingToken = syncToken !== lastSuccessToken;
|
||
|
||
if (isSwitchingToken) {
|
||
// Mode: Switch Account/Token -> Remote Overwrites Local
|
||
try {
|
||
const cloudFavorites = normalizeSongList(await syncService.get('favorites', syncToken) || []);
|
||
|
||
// If cloud has data, use it. If null/empty, we assume new empty account.
|
||
const newFavorites = cloudFavorites;
|
||
|
||
setFavorites(newFavorites);
|
||
setLastSyncedFavorites(newFavorites);
|
||
setLastSuccessToken(syncToken);
|
||
|
||
} catch (e) {
|
||
console.error("Token switch sync failed", e);
|
||
throw e; // Let UI show error
|
||
}
|
||
} else {
|
||
// Mode: Regular Sync -> Merge / Union
|
||
|
||
// Pull Remote
|
||
const cloudFavorites = normalizeSongList(await syncService.get('favorites', syncToken) || []);
|
||
|
||
if (cloudFavorites.length > 0) {
|
||
// Merge: Union
|
||
const merged = [...favorites];
|
||
cloudFavorites.forEach(cloudSong => {
|
||
if (!merged.find(s => s.id === cloudSong.id)) {
|
||
merged.push(cloudSong);
|
||
}
|
||
});
|
||
|
||
// Update Local
|
||
setFavorites(merged);
|
||
setLastSyncedFavorites(merged);
|
||
|
||
// Push Merged back to server
|
||
await syncService.set('favorites', merged, syncToken);
|
||
} else {
|
||
// Remote is empty, push local to it
|
||
if (favorites.length > 0) {
|
||
await syncService.set('favorites', favorites, syncToken);
|
||
setLastSyncedFavorites(favorites);
|
||
}
|
||
}
|
||
// Ensure token is marked as success
|
||
setLastSuccessToken(syncToken);
|
||
}
|
||
};
|
||
|
||
const importNeteasePlaylist = async (id) => {
|
||
setLoading(true);
|
||
try {
|
||
const songs = await api.getPlaylist(id);
|
||
if (songs && songs.length > 0) {
|
||
setFavorites(prev => {
|
||
const newFavs = [...prev];
|
||
let addedCount = 0;
|
||
[...songs].reverse().forEach(song => {
|
||
if (!newFavs.find(s => s.id === song.id)) {
|
||
newFavs.unshift({ ...song, source: 'netease' });
|
||
addedCount++;
|
||
}
|
||
});
|
||
alert(`成功导入 ${addedCount} 首新歌 (共 ${songs.length} 首)`);
|
||
return newFavs;
|
||
});
|
||
|
||
setSyncToken(id);
|
||
setLastSuccessToken(id);
|
||
setSyncMode('netease_playlist');
|
||
|
||
// Trigger cloud sync immediately after state update
|
||
// Note: state update is async, so we might need to rely on the useEffect auto-sync
|
||
// which watches 'favorites' change.
|
||
|
||
// Switch view to favorites to see result
|
||
setView('favorites');
|
||
} else {
|
||
alert("未获取到歌曲,请检查歌单隐私设置或ID是否正确");
|
||
}
|
||
} catch(e) {
|
||
console.error(e);
|
||
alert("导入失败,请稍后重试");
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
// Detect manual token change to reset mode
|
||
useEffect(() => {
|
||
if (!syncToken) {
|
||
setSyncMode('server');
|
||
}
|
||
}, [syncToken]);
|
||
|
||
useEffect(() => {
|
||
if (currentSong) {
|
||
api.getLrc(currentSong.id, currentSong.platform || currentSong.source).then(lrc => {
|
||
setLyrics(parseLrc(lrc));
|
||
});
|
||
} else {
|
||
setLyrics([]);
|
||
}
|
||
}, [currentSong]);
|
||
|
||
useEffect(() => {
|
||
const audio = audioRef.current;
|
||
if (!audio) return;
|
||
audio.volume = volume;
|
||
|
||
const triggerAutoNext = () => {
|
||
if (autoAdvanceLockRef.current) return;
|
||
autoAdvanceLockRef.current = true;
|
||
const isHidden = typeof document !== 'undefined' && document.hidden;
|
||
const immediate = IS_IOS || isHidden;
|
||
playNext(true, { immediate, deferOnIOS: IS_IOS });
|
||
};
|
||
|
||
const isNearEnd = () => {
|
||
const durationSeconds = resolveDurationSeconds(audio, currentSongRef.current);
|
||
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) return false;
|
||
// iOS锁屏时timeupdate频率降低,需要更大的提前量
|
||
const threshold = IS_IOS ? 0.5 : 0.35;
|
||
return audio.currentTime >= durationSeconds - threshold;
|
||
};
|
||
|
||
const updateTime = () => {
|
||
setCurrentTime(audio.currentTime);
|
||
if (autoAdvanceLockRef.current) return;
|
||
// 移除iOS限制:所有平台都使用timeupdate检查,解决iOS锁屏时ended事件不触发的问题
|
||
if (isNearEnd()) triggerAutoNext();
|
||
};
|
||
const updateDuration = () => setDuration(resolveDurationSeconds(audio, currentSongRef.current));
|
||
const onEnded = () => triggerAutoNext();
|
||
const onPause = () => {
|
||
if (!IS_IOS) return;
|
||
if (autoAdvanceLockRef.current) return;
|
||
if (isNearEnd()) triggerAutoNext();
|
||
};
|
||
|
||
audio.addEventListener('timeupdate', updateTime);
|
||
audio.addEventListener('loadedmetadata', updateDuration);
|
||
audio.addEventListener('ended', onEnded);
|
||
audio.addEventListener('pause', onPause);
|
||
|
||
return () => {
|
||
audio.removeEventListener('timeupdate', updateTime);
|
||
audio.removeEventListener('loadedmetadata', updateDuration);
|
||
audio.removeEventListener('ended', onEnded);
|
||
audio.removeEventListener('pause', onPause);
|
||
};
|
||
}, [playlist, currentSong, mode, volume, quality]);
|
||
|
||
useEffect(() => {
|
||
if (currentSong) {
|
||
const audio = audioRef.current;
|
||
if (!audio) return;
|
||
// 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 = audio.src;
|
||
const wasPlaying = isPlaying;
|
||
const deferOnIOS = autoNextPendingRef.current;
|
||
autoNextPendingRef.current = false;
|
||
|
||
// Simple check if src changed significantly (avoiding minor encoding diffs if possible, but exact match is safer)
|
||
if (currentSrc !== url) {
|
||
audio.src = url;
|
||
if (wasPlaying) {
|
||
playAudioWithFallback(audio, { deferOnIOS });
|
||
}
|
||
} else if (deferOnIOS && wasPlaying) {
|
||
playAudioWithFallback(audio, { deferOnIOS: true });
|
||
}
|
||
} else {
|
||
autoNextPendingRef.current = false;
|
||
}
|
||
}, [currentSong, quality]); // Re-run when quality changes
|
||
|
||
useEffect(() => {
|
||
if (currentSong) {
|
||
const audio = audioRef.current;
|
||
if (!audio) return;
|
||
if (isPlaying) playAudioWithFallback(audio);
|
||
else audio.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, options = {}) => {
|
||
if (playlist.length === 0) return;
|
||
|
||
if (auto && mode === 'one') {
|
||
const audio = audioRef.current;
|
||
if (!audio) return;
|
||
audio.currentTime = 0;
|
||
playAudioWithFallback(audio);
|
||
autoAdvanceLockRef.current = false;
|
||
return;
|
||
}
|
||
|
||
if (auto) {
|
||
autoNextPendingRef.current = !options.immediate;
|
||
} else {
|
||
autoNextPendingRef.current = false;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
const nextSong = playlist[nextIdx];
|
||
setCurrentSong(nextSong);
|
||
setIsPlaying(true);
|
||
|
||
if (options.immediate) {
|
||
const audio = audioRef.current;
|
||
if (!audio || !nextSong) return;
|
||
const url = api.getSongUrl(nextSong.id, nextSong.platform || nextSong.source, quality);
|
||
if (audio.src !== url) {
|
||
audio.src = url;
|
||
}
|
||
playAudioWithFallback(audio, { deferOnIOS: options.deferOnIOS });
|
||
}
|
||
};
|
||
|
||
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;
|
||
if (typeof navigator.mediaSession.setPositionState !== 'function') return;
|
||
const audio = audioRef.current;
|
||
if (!audio) return;
|
||
|
||
const duration = resolveDurationSeconds(audio, currentSongRef.current);
|
||
|
||
// 兼容性修复:duration 必须为正有限数,否则某些浏览器(如 Firefox)会隐藏通知栏或报错
|
||
if (!Number.isFinite(duration) || duration <= 0) return;
|
||
|
||
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: Math.min(position, duration)
|
||
});
|
||
} catch (e) {}
|
||
};
|
||
|
||
const handleSeek = (time) => {
|
||
if (Number.isFinite(time)) {
|
||
const audio = audioRef.current;
|
||
if (!audio) return;
|
||
audio.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 normalized = normalizeSongId(song);
|
||
if (!normalized) return;
|
||
const exists = favorites.find(s => s.id === normalized.id);
|
||
if (exists) {
|
||
setFavorites(favorites.filter(s => s.id !== normalized.id));
|
||
} else {
|
||
setFavorites([normalized, ...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
|
||
// 注意:不要调用 setPositionState({ duration: 0 }),因为规范要求 duration 必须为正数
|
||
// 错误的调用可能导致 Firefox 等浏览器隐藏播放控件
|
||
} else {
|
||
navigator.mediaSession.metadata = null;
|
||
}
|
||
}, [currentSong]);
|
||
|
||
// Media Session Logic (Unified)
|
||
useEffect(() => {
|
||
if (!('mediaSession' in navigator)) return;
|
||
|
||
const audio = audioRef.current;
|
||
if (!audio) return;
|
||
|
||
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">
|
||
<audio
|
||
ref={audioRef}
|
||
className="hidden"
|
||
preload="auto"
|
||
playsInline
|
||
webkit-playsinline="true"
|
||
aria-hidden="true"
|
||
/>
|
||
<SideDrawer
|
||
isOpen={showSideDrawer}
|
||
onClose={() => setShowSideDrawer(false)}
|
||
view={view}
|
||
setView={setView}
|
||
quality={quality}
|
||
setQuality={setQuality}
|
||
onClearCache={handleClearCache}
|
||
syncToken={syncToken}
|
||
setSyncToken={setSyncToken}
|
||
onSyncNow={manualSyncFavorites}
|
||
onImportNetease={importNeteasePlaylist}
|
||
syncMode={syncMode}
|
||
/>
|
||
|
||
{/* 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-32 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-32">
|
||
{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}
|
||
mode={mode}
|
||
toggleMode={toggleMode}
|
||
/>
|
||
)}
|
||
</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>
|