Files
Mymusic3/index.html
史悦 a76ef33c4c feat: 添加音乐API代理以解决CORS跨域问题
- index.html: 将API_BASE改为相对路径 /api/music-api
- sync-server/server.js: 新增 /music-api 代理端点转发请求至 music-dl.sayqz.com
- 新增 proxyRequest 函数处理HTTPS代理请求和重定向
2026-01-14 15:17:58 +08:00

1802 lines
90 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta name="theme-color" content="#111827">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<title>没事Music</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: '#1db954',
dark: '#121212',
darker: '#000000',
light: '#b3b3b3',
surface: '#282828',
glass: 'rgba(255, 255, 255, 0.1)',
},
animation: {
'spin-slow': 'spin 8s linear infinite',
'slideUp': 'slideUp 0.3s ease-out',
'slideRight': 'slideRight 0.3s ease-out',
'fadeIn': 'fadeIn 0.3s ease-out',
'pulse-slow': 'pulse 4s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
keyframes: {
slideUp: {
'0%': { transform: 'translateY(100%)' },
'100%': { transform: 'translateY(0)' },
},
slideRight: {
'0%': { transform: 'translateX(-100%)' },
'100%': { transform: 'translateX(0)' },
},
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
}
},
backdropBlur: {
'xs': '2px',
}
}
}
}
</script>
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- React & ReactDOM -->
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<!-- Babel Standalone -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<!-- PWA Manifest Injection -->
<link rel="manifest" id="my-manifest">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
.hide-scrollbar::-webkit-scrollbar { display: none; }
.hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
input[type="range"] {
-webkit-appearance: none;
background: transparent;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
height: 12px;
width: 12px;
border-radius: 50%;
background: #fff;
margin-top: -4px;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
}
input[type="range"]::-webkit-slider-runnable-track {
width: 100%;
height: 4px;
cursor: pointer;
background: rgba(255,255,255,0.2);
border-radius: 2px;
}
.lyric-line {
transition: all 0.3s ease;
opacity: 0.4;
transform: scale(0.95);
filter: blur(0.5px);
}
.lyric-line.active {
opacity: 1;
color: #1db954;
transform: scale(1.05);
font-weight: bold;
filter: blur(0);
text-shadow: 0 0 10px rgba(29, 185, 84, 0.3);
}
.glass-panel {
background: rgba(30, 30, 30, 0.6);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.text-shadow {
text-shadow: 0 2px 4px rgba(0,0,0,0.5);
}
/* Custom Select in Drawer */
.drawer-select {
background-color: rgba(255,255,255,0.1);
color: white;
border: 1px solid rgba(255,255,255,0.2);
padding: 8px 12px;
border-radius: 8px;
width: 100%;
outline: none;
appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 16px;
}
.drawer-select option {
background-color: #1f2937;
color: white;
}
</style>
</head>
<body class="bg-darker text-white h-screen flex flex-col overflow-hidden select-none">
<div id="root" class="h-full w-full"></div>
<script type="text/babel">
const { useState, useEffect, useRef, useMemo, useCallback } = React;
const API_BASE = "/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}/api/?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}/api/?source=${source}&id=${id}&type=url&br=${br}`;
},
getPicUrl: (id, source) => {
return `${API_BASE}/api/?source=${source}&id=${id}&type=pic`;
},
getLrc: async (id, source) => {
try {
const res = await fetch(`${API_BASE}/api/?source=${source}&id=${id}&type=lrc`);
const text = await res.text();
return text;
} catch (e) {
console.error("Get LRC failed", e);
return "";
}
},
getTopLists: async (source) => {
const predefinedLists = {
'netease': [
{ id: '3778678', name: '热歌榜', cover: 'https://p1.music.126.net/GhhuF6Ep5Tq9IEvLsyCN7w==/18708190348493229.jpg' },
{ id: '19723756', name: '飙升榜', cover: 'https://p1.music.126.net/DrRIg6CrgDfVLEph9SNh7w==/18696095720518497.jpg' },
{ id: '3779629', name: '新歌榜', cover: 'https://p1.music.126.net/N2HO5j8f9yVT9OIyZ7-gRA==/19068263547447148.jpg' },
{ id: '2884035', name: '原创榜', cover: 'https://p1.music.126.net/sBzD11nforcuh1jdLSgX7g==/18740076185638788.jpg' }
],
'qq': [
{ id: '26', name: '热歌榜', cover: 'https://y.gtimg.cn/music/photo_new/T003R300x300M000003z4Uoe1HTPH1.jpg' },
{ id: '4', name: '流行指数榜', cover: 'https://y.gtimg.cn/music/photo_new/T003R300x300M000000VjNKo0IG7zL.jpg' },
{ id: '27', name: '新歌榜', cover: 'https://y.gtimg.cn/music/photo_new/T003R300x300M000002AgqFp0r6taf.jpg' }
],
'kuwo': [
{ id: '93', name: '酷我飙升榜', cover: 'http://img1.kwcdn.kuwo.cn/star/upload/10/10/1485072610.jpg' },
{ id: '17', name: '酷我新歌榜', cover: 'http://img1.kwcdn.kuwo.cn/star/upload/11/11/1485072611.jpg' },
{ id: '16', name: '酷我热歌榜', cover: 'http://img1.kwcdn.kuwo.cn/star/upload/3/3/1485072603.jpg' }
],
'kugou': [
{ id: '8888', name: '酷狗TOP500', cover: 'http://imge.kugou.com/mcommon/400/20150331/20150331161102575467.png' },
{ id: '6666', name: '飙升榜', cover: 'http://imge.kugou.com/mcommon/400/20150331/20150331161100806437.png' }
]
};
try {
const res = await fetch(`${API_BASE}/api/?source=${source}&type=toplists`);
const data = await res.json();
if (data.code === 200) {
// 兼容多种返回结构data本身是数组或者data.list是数组
const list = Array.isArray(data.data) ? data.data : (data.data?.list || data.data?.topList || []);
if (Array.isArray(list) && list.length > 0) {
return list;
}
}
} catch (e) {
console.warn("Failed to fetch remote toplists, falling back to local list", e);
}
return predefinedLists[source] || [];
},
getTopListSongs: async (id, source) => {
try {
const res = await fetch(`${API_BASE}/api/?source=${source}&id=${id}&type=toplist`);
const data = await res.json();
if (data.code === 200) {
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}/api/?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>&copy; 2026 没想好 Music</p>
</div>
</div>
</div>
);
};
const MiniPlayer = ({ currentSong, isPlaying, togglePlay, onExpand, progress, duration }) => {
if (!currentSong) return null;
const progressPercent = duration ? (progress / duration) * 100 : 0;
return (
<div className="fixed bottom-0 left-0 right-0 glass-panel z-40 flex flex-col pb-safe animate-[slideUp_0.3s_ease-out]">
<div className="h-0.5 w-full bg-gray-700/50">
<div className="h-full bg-primary transition-all duration-300 shadow-[0_0_10px_rgba(29,185,84,0.5)]" style={{ width: `${progressPercent}%` }}></div>
</div>
<div className="flex items-center p-3" onClick={onExpand}>
<div className={`w-10 h-10 rounded-full overflow-hidden flex-shrink-0 border border-white/10 ${isPlaying ? 'animate-spin-slow' : ''}`}>
<ImageWithFallback
src={api.getPicUrl(currentSong.id, currentSong.platform || currentSong.source)}
className="w-full h-full object-cover"
/>
</div>
<div className="ml-3 flex-1 min-w-0">
<div className="text-sm font-medium text-white truncate">{currentSong.name}</div>
<div className="text-xs text-gray-400 truncate">
{currentSong.artist} {currentSong.album ? `· ${currentSong.album}` : ''}
</div>
</div>
<div className="flex items-center gap-4 px-2">
<div onClick={(e) => { e.stopPropagation(); togglePlay(); }} className="w-10 h-10 flex items-center justify-center rounded-full border border-white/20 hover:bg-white/10 active:scale-95 transition-all">
<Icon name={isPlaying ? "pause" : "play"} />
</div>
<Icon name="list" onClick={(e) => { e.stopPropagation(); onExpand('playlist'); }} />
</div>
</div>
</div>
);
};
const FullPlayer = ({ currentSong, isPlaying, togglePlay, onClose, progress, duration, seek, prev, next, lyrics, mode, toggleMode, volume, setVolume, isLiked, toggleLike }) => {
const [showLyrics, setShowLyrics] = useState(false);
const lyricContainerRef = useRef(null);
useEffect(() => {
if (showLyrics && lyricContainerRef.current && lyrics.length > 0) {
const activeIndex = lyrics.findIndex((l, i) => l.time <= progress && (lyrics[i+1]?.time > progress || i === lyrics.length - 1));
if (activeIndex !== -1) {
const activeEl = lyricContainerRef.current.children[activeIndex];
if (activeEl) {
activeEl.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}
}, [progress, showLyrics, lyrics]);
if (!currentSong) return null;
const activeLyricIndex = lyrics.findIndex((l, i) => l.time <= progress && (lyrics[i+1]?.time > progress || i === lyrics.length - 1));
const modeIcon = mode === 'loop' ? 'repeat' : (mode === 'one' ? '1' : 'shuffle');
return (
<div className="fixed inset-0 bg-darker z-50 flex flex-col animate-[slideUp_0.3s_ease-out]">
{/* Dynamic Background */}
<div className="absolute inset-0 z-0 overflow-hidden">
<img
src={api.getPicUrl(currentSong.id, currentSong.platform || currentSong.source)}
className="w-full h-full object-cover blur-3xl opacity-40 scale-150 animate-pulse-slow"
/>
<div className="absolute inset-0 bg-gradient-to-b from-black/30 via-black/60 to-black/90"></div>
</div>
{/* Header */}
<div className="relative z-10 flex items-center justify-between p-4 pt-8">
<Icon name="chevron-down" size="text-xl" onClick={onClose} className="p-2" />
<div className="text-center">
<div className="text-xs text-gray-300 uppercase tracking-widest font-semibold">正在播放</div>
</div>
<Icon name="ellipsis" size="text-xl" className="p-2" />
</div>
{/* Content */}
<div className="relative z-10 flex-1 flex flex-col items-center justify-center p-6 overflow-hidden" onClick={() => setShowLyrics(!showLyrics)}>
{!showLyrics ? (
<div className="w-full aspect-square max-w-sm rounded-2xl overflow-hidden shadow-[0_20px_50px_rgba(0,0,0,0.5)] mb-8 relative animate-[fadeIn_0.5s] border border-white/10">
<ImageWithFallback
src={api.getPicUrl(currentSong.id, currentSong.platform || currentSong.source)}
className="w-full h-full object-cover"
/>
</div>
) : (
<div className="w-full h-full overflow-y-auto hide-scrollbar text-center py-4 mask-image-gradient" ref={lyricContainerRef}>
{lyrics.length > 0 ? lyrics.map((line, i) => (
<p
key={i}
className={`lyric-line py-3 text-lg transition-all duration-500 ${i === activeLyricIndex ? 'active' : 'text-gray-400'}`}
>
{line.text}
</p>
)) : (
<p className="text-gray-500 mt-20">暂无歌词</p>
)}
</div>
)}
<div className="w-full mb-2 px-4 flex justify-between items-center">
<div className="flex-1 min-w-0 mr-4">
<h2 className="text-2xl font-bold text-white truncate mb-1 text-shadow">{currentSong.name}</h2>
<p className="text-lg text-gray-300 truncate">
{currentSong.artist} {currentSong.album ? `· ${currentSong.album}` : ''}
</p>
</div>
<Icon
name="heart"
size="text-2xl"
className={`${isLiked ? "text-red-500" : "text-white/50 hover:text-white"} transition-colors`}
onClick={(e) => {
e.stopPropagation();
// 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>