Merge branch 'dev' of https://github.com/mtvpls/moontvplus-dev into dev
This commit is contained in:
@@ -353,6 +353,9 @@ interface SiteConfig {
|
||||
OIDCClientId?: string;
|
||||
OIDCClientSecret?: string;
|
||||
OIDCButtonText?: string;
|
||||
TuneHubEnabled?: boolean;
|
||||
TuneHubBaseUrl?: string;
|
||||
TuneHubApiKey?: string;
|
||||
}
|
||||
|
||||
// 视频源数据类型
|
||||
@@ -6790,6 +6793,9 @@ const SiteConfigComponent = ({
|
||||
OIDCClientId: '',
|
||||
OIDCClientSecret: '',
|
||||
OIDCButtonText: '',
|
||||
TuneHubEnabled: false,
|
||||
TuneHubBaseUrl: 'https://tunehub.sayqz.com/api',
|
||||
TuneHubApiKey: '',
|
||||
});
|
||||
|
||||
// 豆瓣数据源相关状态
|
||||
@@ -7666,6 +7672,93 @@ const SiteConfigComponent = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TuneHub 音乐配置 */}
|
||||
<div className='space-y-4 pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||
<h3 className='text-sm font-semibold text-gray-900 dark:text-gray-100'>
|
||||
TuneHub 音乐配置
|
||||
</h3>
|
||||
|
||||
{/* 开启 TuneHub */}
|
||||
<div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
开启音乐功能
|
||||
</label>
|
||||
<button
|
||||
type='button'
|
||||
onClick={() =>
|
||||
setSiteSettings((prev) => ({
|
||||
...prev,
|
||||
TuneHubEnabled: !prev.TuneHubEnabled,
|
||||
}))
|
||||
}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 ${
|
||||
siteSettings.TuneHubEnabled
|
||||
? buttonStyles.toggleOn
|
||||
: buttonStyles.toggleOff
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full ${
|
||||
buttonStyles.toggleThumb
|
||||
} transition-transform ${
|
||||
siteSettings.TuneHubEnabled
|
||||
? buttonStyles.toggleThumbOn
|
||||
: buttonStyles.toggleThumbOff
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
开启后将在首页显示音乐视听入口,支持网易云、QQ音乐、酷我音乐
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* TuneHub Base URL */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
TuneHub API 地址
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
placeholder='https://tunehub.sayqz.com/api'
|
||||
value={siteSettings.TuneHubBaseUrl}
|
||||
onChange={(e) =>
|
||||
setSiteSettings((prev) => ({
|
||||
...prev,
|
||||
TuneHubBaseUrl: e.target.value,
|
||||
}))
|
||||
}
|
||||
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||
/>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
TuneHub API 的基础地址,默认为 https://tunehub.sayqz.com/api。也可以通过环境变量 TUNEHUB_BASE_URL 配置
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* TuneHub API Key */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
TuneHub API Key
|
||||
</label>
|
||||
<input
|
||||
type='password'
|
||||
placeholder='th_your_api_key_here'
|
||||
value={siteSettings.TuneHubApiKey}
|
||||
onChange={(e) =>
|
||||
setSiteSettings((prev) => ({
|
||||
...prev,
|
||||
TuneHubApiKey: e.target.value,
|
||||
}))
|
||||
}
|
||||
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-green-500 focus:border-transparent'
|
||||
/>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
用于解析歌曲播放链接的 API Key(消耗积分)。搜索、榜单、歌单等功能不需要 Key。也可以通过环境变量 TUNEHUB_API_KEY 配置
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className='flex justify-end'>
|
||||
<button
|
||||
|
||||
@@ -69,6 +69,9 @@ export async function POST(request: NextRequest) {
|
||||
OIDCClientSecret,
|
||||
OIDCButtonText,
|
||||
OIDCMinTrustLevel,
|
||||
TuneHubEnabled,
|
||||
TuneHubBaseUrl,
|
||||
TuneHubApiKey,
|
||||
} = body as {
|
||||
SiteName: string;
|
||||
Announcement: string;
|
||||
@@ -110,6 +113,9 @@ export async function POST(request: NextRequest) {
|
||||
OIDCClientSecret?: string;
|
||||
OIDCButtonText?: string;
|
||||
OIDCMinTrustLevel?: number;
|
||||
TuneHubEnabled?: boolean;
|
||||
TuneHubBaseUrl?: string;
|
||||
TuneHubApiKey?: string;
|
||||
};
|
||||
|
||||
// 参数校验
|
||||
@@ -150,7 +156,10 @@ export async function POST(request: NextRequest) {
|
||||
(OIDCClientId !== undefined && typeof OIDCClientId !== 'string') ||
|
||||
(OIDCClientSecret !== undefined && typeof OIDCClientSecret !== 'string') ||
|
||||
(OIDCButtonText !== undefined && typeof OIDCButtonText !== 'string') ||
|
||||
(OIDCMinTrustLevel !== undefined && typeof OIDCMinTrustLevel !== 'number')
|
||||
(OIDCMinTrustLevel !== undefined && typeof OIDCMinTrustLevel !== 'number') ||
|
||||
(TuneHubEnabled !== undefined && typeof TuneHubEnabled !== 'boolean') ||
|
||||
(TuneHubBaseUrl !== undefined && typeof TuneHubBaseUrl !== 'string') ||
|
||||
(TuneHubApiKey !== undefined && typeof TuneHubApiKey !== 'string')
|
||||
) {
|
||||
return NextResponse.json({ error: '参数格式错误' }, { status: 400 });
|
||||
}
|
||||
@@ -207,6 +216,9 @@ export async function POST(request: NextRequest) {
|
||||
OIDCClientSecret,
|
||||
OIDCButtonText,
|
||||
OIDCMinTrustLevel,
|
||||
TuneHubEnabled,
|
||||
TuneHubBaseUrl,
|
||||
TuneHubApiKey,
|
||||
};
|
||||
|
||||
// 写入数据库
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||
import { db } from '@/lib/db';
|
||||
import { MusicPlayRecord } from '@/lib/db.client';
|
||||
import { getCachedSongs, setCachedSong } from '@/lib/music-song-cache';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// 从 cookie 获取用户信息
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
// 检查用户状态
|
||||
if (authInfo.username !== process.env.USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||
if (!userInfoV2) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (userInfoV2.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const records = await db.getAllMusicPlayRecords(authInfo.username);
|
||||
|
||||
// 从缓存中获取歌曲信息并填充到记录中
|
||||
const keys = Object.keys(records).map(key => {
|
||||
const [platform, id] = key.split('+');
|
||||
return { platform, id };
|
||||
});
|
||||
const cachedSongs = getCachedSongs(keys);
|
||||
|
||||
// 将缓存的歌曲信息合并到记录中
|
||||
const enrichedRecords: Record<string, MusicPlayRecord> = {};
|
||||
for (const [key, record] of Object.entries(records)) {
|
||||
const cachedSong = cachedSongs.get(key);
|
||||
enrichedRecords[key] = {
|
||||
...record,
|
||||
name: cachedSong?.name || record.name,
|
||||
artist: cachedSong?.artist || record.artist,
|
||||
album: cachedSong?.album || record.album,
|
||||
pic: cachedSong?.pic || record.pic,
|
||||
};
|
||||
}
|
||||
|
||||
return NextResponse.json(enrichedRecords, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error('获取音乐播放记录失败', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
// 从 cookie 获取用户信息
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (authInfo.username !== process.env.USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||
if (!userInfoV2) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (userInfoV2.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { key, record }: { key: string; record: MusicPlayRecord } = body;
|
||||
|
||||
if (!key || !record) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing key or record' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 验证音乐播放记录数据
|
||||
if (!record.platform || !record.id || !record.name || !record.artist) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid record data' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 从key中解析platform和id
|
||||
const [platform, id] = key.split('+');
|
||||
if (!platform || !id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid key format' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await db.saveMusicPlayRecord(authInfo.username, platform, id, record);
|
||||
|
||||
// 缓存歌曲信息到服务器内存
|
||||
setCachedSong(platform, id, {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
artist: record.artist,
|
||||
album: record.album,
|
||||
pic: record.pic,
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error('保存音乐播放记录失败', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
// 从 cookie 获取用户信息
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
if (authInfo.username !== process.env.USERNAME) {
|
||||
// 非站长,检查用户存在或被封禁
|
||||
const userInfoV2 = await db.getUserInfoV2(authInfo.username);
|
||||
if (!userInfoV2) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 401 });
|
||||
}
|
||||
if (userInfoV2.banned) {
|
||||
return NextResponse.json({ error: '用户已被封禁' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const key = searchParams.get('key');
|
||||
|
||||
if (key) {
|
||||
// 删除单条记录
|
||||
const [platform, id] = key.split('+');
|
||||
if (!platform || !id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid key format' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
await db.deleteMusicPlayRecord(authInfo.username, platform, id);
|
||||
} else {
|
||||
// 清空所有记录
|
||||
await db.clearAllMusicPlayRecords(authInfo.username);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true }, { status: 200 });
|
||||
} catch (err) {
|
||||
console.error('删除音乐播放记录失败', err);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal Server Error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
// 代理音频流
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const url = searchParams.get('url');
|
||||
|
||||
if (!url) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少 url 参数' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 安全检查:只允许代理音乐平台的音频和图片 CDN
|
||||
const allowedDomains = [
|
||||
'sycdn.kuwo.cn',
|
||||
'kwcdn.kuwo.cn',
|
||||
'img1.kwcdn.kuwo.cn',
|
||||
'img2.kwcdn.kuwo.cn',
|
||||
'img3.kwcdn.kuwo.cn',
|
||||
'img4.kwcdn.kuwo.cn',
|
||||
'music.163.com',
|
||||
'y.qq.com',
|
||||
'ws.stream.qqmusic.qq.com',
|
||||
'isure.stream.qqmusic.qq.com',
|
||||
'dl.stream.qqmusic.qq.com',
|
||||
];
|
||||
|
||||
let urlObj: URL;
|
||||
try {
|
||||
urlObj = new URL(url);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: '无效的 URL' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const isAllowed = allowedDomains.some(domain =>
|
||||
urlObj.hostname === domain || urlObj.hostname.endsWith(`.${domain}`)
|
||||
);
|
||||
|
||||
if (!isAllowed) {
|
||||
console.warn(`拒绝代理音频请求: ${urlObj.hostname}`);
|
||||
return NextResponse.json(
|
||||
{ error: '不允许的目标域名' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// 检查是否有 Range 请求头
|
||||
const range = request.headers.get('range');
|
||||
|
||||
// 构建上游请求头
|
||||
const upstreamHeaders: Record<string, string> = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
'Referer': 'http://www.kuwo.cn/',
|
||||
};
|
||||
|
||||
// 如果有 Range 请求,转发给上游
|
||||
if (range) {
|
||||
upstreamHeaders['Range'] = range;
|
||||
}
|
||||
|
||||
// 发起请求获取音频流
|
||||
const response = await fetch(url, {
|
||||
headers: upstreamHeaders,
|
||||
});
|
||||
|
||||
if (!response.ok && response.status !== 206) {
|
||||
return NextResponse.json(
|
||||
{ error: '获取音频失败' },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
// 获取响应头
|
||||
const contentType = response.headers.get('content-type') || 'audio/mpeg';
|
||||
const contentLength = response.headers.get('content-length');
|
||||
const contentRange = response.headers.get('content-range');
|
||||
const acceptRanges = response.headers.get('accept-ranges');
|
||||
|
||||
// 创建响应头
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': contentType,
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Accept-Ranges': acceptRanges || 'bytes',
|
||||
};
|
||||
|
||||
if (contentLength) {
|
||||
headers['Content-Length'] = contentLength;
|
||||
}
|
||||
|
||||
// 如果上游返回了 Content-Range,转发给客户端
|
||||
if (contentRange) {
|
||||
headers['Content-Range'] = contentRange;
|
||||
}
|
||||
|
||||
// 返回音频流,保持原始状态码(200 或 206)
|
||||
return new NextResponse(response.body, {
|
||||
status: response.status,
|
||||
headers,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('代理音频失败:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '代理请求失败',
|
||||
details: (error as Error).message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
// 服务器端内存缓存
|
||||
const serverCache = {
|
||||
methodConfigs: new Map<string, { data: any; timestamp: number }>(),
|
||||
proxyRequests: new Map<string, { data: any; timestamp: number }>(),
|
||||
CACHE_DURATION: 24 * 60 * 60 * 1000, // 24小时缓存
|
||||
};
|
||||
|
||||
// 获取 TuneHub 配置
|
||||
async function getTuneHubConfig() {
|
||||
const config = await getConfig();
|
||||
const siteConfig = config?.SiteConfig;
|
||||
|
||||
const enabled = siteConfig?.TuneHubEnabled ?? false;
|
||||
const baseUrl =
|
||||
siteConfig?.TuneHubBaseUrl ||
|
||||
process.env.TUNEHUB_BASE_URL ||
|
||||
'https://tunehub.sayqz.com/api';
|
||||
const apiKey = siteConfig?.TuneHubApiKey || process.env.TUNEHUB_API_KEY || '';
|
||||
|
||||
return { enabled, baseUrl, apiKey };
|
||||
}
|
||||
|
||||
// 通用请求处理函数
|
||||
async function proxyRequest(
|
||||
url: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
...options.headers,
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
},
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('TuneHub API 请求失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取方法配置并执行请求
|
||||
async function executeMethod(
|
||||
baseUrl: string,
|
||||
platform: string,
|
||||
func: string,
|
||||
variables: Record<string, string> = {}
|
||||
): Promise<any> {
|
||||
// 1. 获取方法配置
|
||||
const cacheKey = `method-config-${platform}-${func}`;
|
||||
let config: any;
|
||||
|
||||
const cached = serverCache.methodConfigs.get(cacheKey);
|
||||
if (cached && Date.now() - cached.timestamp < serverCache.CACHE_DURATION) {
|
||||
config = cached.data.data;
|
||||
} else {
|
||||
const response = await proxyRequest(`${baseUrl}/v1/methods/${platform}/${func}`);
|
||||
const data = await response.json();
|
||||
serverCache.methodConfigs.set(cacheKey, { data, timestamp: Date.now() });
|
||||
config = data.data;
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
throw new Error('无法获取方法配置');
|
||||
}
|
||||
|
||||
// 2. 替换模板变量
|
||||
let url = config.url;
|
||||
const params: Record<string, string> = {};
|
||||
|
||||
// 先将 variables 中的值转换为可执行的变量
|
||||
const evalContext: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(variables)) {
|
||||
// 尝试将字符串转换为数字(如果可能)
|
||||
const numValue = Number(value);
|
||||
evalContext[key] = isNaN(numValue) ? value : numValue;
|
||||
}
|
||||
|
||||
// 递归处理对象中的模板变量
|
||||
function processTemplateValue(value: any): any {
|
||||
if (typeof value === 'string') {
|
||||
// 处理包含模板变量的表达式
|
||||
const expressionRegex = /\{\{(.+?)\}\}/g;
|
||||
return value.replace(expressionRegex, (match, expression) => {
|
||||
try {
|
||||
// 创建一个函数来执行表达式,传入所有变量作为参数
|
||||
// eslint-disable-next-line no-new-func
|
||||
const func = new Function(...Object.keys(evalContext), `return ${expression}`);
|
||||
const result = func(...Object.values(evalContext));
|
||||
return String(result);
|
||||
} catch (err) {
|
||||
console.error(`[executeMethod] 执行表达式失败: ${expression}`, err);
|
||||
return '0'; // 默认值
|
||||
}
|
||||
});
|
||||
} else if (Array.isArray(value)) {
|
||||
return value.map(item => processTemplateValue(item));
|
||||
} else if (typeof value === 'object' && value !== null) {
|
||||
const result: any = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
result[k] = processTemplateValue(v);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
// 处理 URL 参数
|
||||
if (config.params) {
|
||||
for (const [key, value] of Object.entries(config.params)) {
|
||||
params[key] = processTemplateValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
// 处理 POST body
|
||||
let processedBody = config.body;
|
||||
if (config.body) {
|
||||
processedBody = processTemplateValue(config.body);
|
||||
}
|
||||
|
||||
// 3. 构建完整 URL
|
||||
if (config.method === 'GET' && Object.keys(params).length > 0) {
|
||||
const urlObj = new URL(url);
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
urlObj.searchParams.append(key, value);
|
||||
}
|
||||
url = urlObj.toString();
|
||||
}
|
||||
|
||||
// 4. 发起请求
|
||||
const requestOptions: RequestInit = {
|
||||
method: config.method || 'GET',
|
||||
headers: config.headers || {},
|
||||
};
|
||||
|
||||
if (config.method === 'POST' && processedBody) {
|
||||
requestOptions.body = JSON.stringify(processedBody);
|
||||
requestOptions.headers = {
|
||||
...requestOptions.headers,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
const response = await proxyRequest(url, requestOptions);
|
||||
let data = await response.json();
|
||||
|
||||
// 5. 执行 transform 函数(如果有)
|
||||
if (config.transform) {
|
||||
try {
|
||||
// eslint-disable-next-line no-eval
|
||||
const transformFn = eval(`(${config.transform})`);
|
||||
data = transformFn(data);
|
||||
} catch (err) {
|
||||
console.error('[executeMethod] Transform 函数执行失败:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. 处理酷我音乐的图片 URL(转换为代理 URL)
|
||||
if (platform === 'kuwo') {
|
||||
const processKuwoImages = (obj: any): any => {
|
||||
if (typeof obj === 'string' && obj.startsWith('http://') && obj.includes('kwcdn.kuwo.cn')) {
|
||||
// 将 HTTP 图片 URL 转换为代理 URL
|
||||
return `/api/music/proxy?url=${encodeURIComponent(obj)}`;
|
||||
} else if (Array.isArray(obj)) {
|
||||
return obj.map(item => processKuwoImages(item));
|
||||
} else if (typeof obj === 'object' && obj !== null) {
|
||||
const result: any = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
result[key] = processKuwoImages(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
|
||||
data = processKuwoImages(data);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// GET 请求处理
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { enabled, baseUrl } = await getTuneHubConfig();
|
||||
|
||||
if (!enabled) {
|
||||
return NextResponse.json(
|
||||
{ error: '音乐功能未开启' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const action = searchParams.get('action');
|
||||
|
||||
if (!action) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少 action 参数' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// 处理不同的 action
|
||||
switch (action) {
|
||||
case 'toplists': {
|
||||
// 获取排行榜列表
|
||||
const platform = searchParams.get('platform');
|
||||
if (!platform) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少 platform 参数' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const cacheKey = `toplists-${platform}`;
|
||||
const cached = serverCache.proxyRequests.get(cacheKey);
|
||||
|
||||
if (cached && Date.now() - cached.timestamp < serverCache.CACHE_DURATION) {
|
||||
return NextResponse.json(cached.data);
|
||||
}
|
||||
|
||||
const data = await executeMethod(baseUrl, platform, 'toplists');
|
||||
serverCache.proxyRequests.set(cacheKey, { data, timestamp: Date.now() });
|
||||
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
|
||||
case 'toplist': {
|
||||
// 获取排行榜详情
|
||||
const platform = searchParams.get('platform');
|
||||
const id = searchParams.get('id');
|
||||
|
||||
if (!platform || !id) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少 platform 或 id 参数' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const cacheKey = `toplist-${platform}-${id}`;
|
||||
const cached = serverCache.proxyRequests.get(cacheKey);
|
||||
|
||||
if (cached && Date.now() - cached.timestamp < serverCache.CACHE_DURATION) {
|
||||
return NextResponse.json(cached.data);
|
||||
}
|
||||
|
||||
const data = await executeMethod(baseUrl, platform, 'toplist', { id });
|
||||
serverCache.proxyRequests.set(cacheKey, { data, timestamp: Date.now() });
|
||||
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
|
||||
case 'playlist': {
|
||||
// 获取歌单详情
|
||||
const platform = searchParams.get('platform');
|
||||
const id = searchParams.get('id');
|
||||
|
||||
if (!platform || !id) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少 platform 或 id 参数' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const cacheKey = `playlist-${platform}-${id}`;
|
||||
const cached = serverCache.proxyRequests.get(cacheKey);
|
||||
|
||||
if (cached && Date.now() - cached.timestamp < serverCache.CACHE_DURATION) {
|
||||
return NextResponse.json(cached.data);
|
||||
}
|
||||
|
||||
const data = await executeMethod(baseUrl, platform, 'playlist', { id });
|
||||
serverCache.proxyRequests.set(cacheKey, { data, timestamp: Date.now() });
|
||||
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
|
||||
case 'search': {
|
||||
// 搜索歌曲
|
||||
const platform = searchParams.get('platform');
|
||||
const keyword = searchParams.get('keyword');
|
||||
const page = searchParams.get('page') || '1';
|
||||
const pageSize = searchParams.get('pageSize') || '20';
|
||||
|
||||
if (!platform || !keyword) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少 platform 或 keyword 参数' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const cacheKey = `search-${platform}-${keyword}-${page}-${pageSize}`;
|
||||
const cached = serverCache.proxyRequests.get(cacheKey);
|
||||
|
||||
if (cached && Date.now() - cached.timestamp < serverCache.CACHE_DURATION) {
|
||||
return NextResponse.json(cached.data);
|
||||
}
|
||||
|
||||
// 注意:不同平台可能使用不同的变量名
|
||||
// 统一传递 keyword, page, pageSize, limit (limit = pageSize)
|
||||
const data = await executeMethod(baseUrl, platform, 'search', {
|
||||
keyword,
|
||||
page,
|
||||
pageSize,
|
||||
limit: pageSize, // 有些平台使用 limit 而不是 pageSize
|
||||
});
|
||||
|
||||
serverCache.proxyRequests.set(cacheKey, { data, timestamp: Date.now() });
|
||||
|
||||
return NextResponse.json(data);
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ error: '不支持的 action' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('音乐 API 错误:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '请求失败',
|
||||
details: (error as Error).message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// POST 请求处理(用于解析歌曲)
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { enabled, baseUrl, apiKey } = await getTuneHubConfig();
|
||||
|
||||
if (!enabled) {
|
||||
return NextResponse.json(
|
||||
{ error: '音乐功能未开启' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { action } = body;
|
||||
|
||||
if (!action) {
|
||||
return NextResponse.json(
|
||||
{ error: '缺少 action 参数' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'parse': {
|
||||
// 解析歌曲(需要 API Key)
|
||||
if (!apiKey) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: -1,
|
||||
error: '未配置 TuneHub API Key',
|
||||
message: '未配置 TuneHub API Key'
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const { platform, ids, quality } = body;
|
||||
if (!platform || !ids) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
code: -1,
|
||||
error: '缺少 platform 或 ids 参数',
|
||||
message: '缺少 platform 或 ids 参数'
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await proxyRequest(`${baseUrl}/v1/parse`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': apiKey,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
platform,
|
||||
ids,
|
||||
quality: quality || '320k',
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
console.log('TuneHub 解析响应:', data);
|
||||
|
||||
// 如果 TuneHub 返回错误,包装成统一格式
|
||||
if (!response.ok || data.code !== 0) {
|
||||
return NextResponse.json({
|
||||
code: data.code || -1,
|
||||
message: data.message || data.error || '解析失败',
|
||||
error: data.error || data.message || '解析失败',
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
console.error('解析歌曲失败:', error);
|
||||
return NextResponse.json({
|
||||
code: -1,
|
||||
message: '解析请求失败',
|
||||
error: (error as Error).message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return NextResponse.json(
|
||||
{ error: '不支持的 action' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('音乐 API 错误:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: '请求失败',
|
||||
details: (error as Error).message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,7 @@ export default async function RootLayout({
|
||||
let enableMovieRequest = true;
|
||||
let webLiveEnabled = false;
|
||||
let customAdFilterVersion = 0;
|
||||
let tuneHubEnabled = false;
|
||||
let customCategories = [] as {
|
||||
name: string;
|
||||
type: 'movie' | 'tv';
|
||||
@@ -134,6 +135,8 @@ export default async function RootLayout({
|
||||
webLiveEnabled = config.WebLiveEnabled ?? false;
|
||||
// 自定义去广告代码版本号
|
||||
customAdFilterVersion = config.SiteConfig?.CustomAdFilterVersion || 0;
|
||||
// TuneHub音乐功能配置
|
||||
tuneHubEnabled = config.SiteConfig?.TuneHubEnabled || false;
|
||||
// 检查是否启用了 OpenList 功能
|
||||
openListEnabled = !!(
|
||||
config.OpenListConfig?.Enabled &&
|
||||
@@ -191,6 +194,7 @@ export default async function RootLayout({
|
||||
ENABLE_MOVIE_REQUEST: enableMovieRequest,
|
||||
WEB_LIVE_ENABLED: webLiveEnabled,
|
||||
CUSTOM_AD_FILTER_VERSION: customAdFilterVersion,
|
||||
TUNEHUB_ENABLED: tuneHubEnabled,
|
||||
FESTIVE_EFFECT_ENABLED:
|
||||
process.env.FESTIVE_EFFECT_ENABLED === 'true',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,1635 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
getAllMusicPlayRecords,
|
||||
saveMusicPlayRecord,
|
||||
MusicPlayRecord,
|
||||
deleteMusicPlayRecord,
|
||||
clearAllMusicPlayRecords,
|
||||
} from '@/lib/db.client';
|
||||
|
||||
interface Song {
|
||||
id: string;
|
||||
name: string;
|
||||
artist: string;
|
||||
album?: string;
|
||||
pic?: string;
|
||||
platform?: 'netease' | 'qq' | 'kuwo'; // 添加平台信息
|
||||
}
|
||||
|
||||
interface PlayRecord {
|
||||
platform: 'netease' | 'qq' | 'kuwo';
|
||||
id: string;
|
||||
playTime: number; // 播放时间(秒)
|
||||
duration: number; // 总时长(秒)
|
||||
timestamp: number; // 添加时间戳
|
||||
}
|
||||
|
||||
interface LyricLine {
|
||||
time: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface Playlist {
|
||||
id: string;
|
||||
name: string;
|
||||
pic: string;
|
||||
updateFrequency?: string;
|
||||
}
|
||||
|
||||
export default function MusicPage() {
|
||||
const router = useRouter();
|
||||
const [currentSource, setCurrentSource] = useState<'netease' | 'qq' | 'kuwo'>('netease');
|
||||
const [playlists, setPlaylists] = useState<Playlist[]>([]);
|
||||
const [songs, setSongs] = useState<Song[]>([]);
|
||||
const [currentView, setCurrentView] = useState<'playlists' | 'songs'>('playlists');
|
||||
const [currentPlaylistTitle, setCurrentPlaylistTitle] = useState('');
|
||||
const [searchKeyword, setSearchKeyword] = useState('');
|
||||
const [currentSong, setCurrentSong] = useState<Song | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [volume, setVolume] = useState(100);
|
||||
const [quality, setQuality] = useState<'128k' | '320k' | 'flac' | 'flac24bit'>('320k');
|
||||
const [playMode, setPlayMode] = useState<'loop' | 'single' | 'random'>('loop');
|
||||
const [currentSongIndex, setCurrentSongIndex] = useState(-1);
|
||||
const [showPlayer, setShowPlayer] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showLyrics, setShowLyrics] = useState(false);
|
||||
const [lyrics, setLyrics] = useState<LyricLine[]>([]);
|
||||
const [currentLyricIndex, setCurrentLyricIndex] = useState(-1);
|
||||
const [currentSongUrl, setCurrentSongUrl] = useState('');
|
||||
const [playRecords, setPlayRecords] = useState<PlayRecord[]>([]); // 播放记录(只存平台和ID)
|
||||
const [playlist, setPlaylist] = useState<Song[]>([]); // 完整歌曲信息(用于显示)
|
||||
const [showPlaylist, setShowPlaylist] = useState(false);
|
||||
const [playlistIndex, setPlaylistIndex] = useState(-1); // 当前在播放列表中的索引
|
||||
const [showQualityMenu, setShowQualityMenu] = useState(false); // 音质选择菜单
|
||||
const [showVolumeSlider, setShowVolumeSlider] = useState(false); // 音量滑块显示状态
|
||||
const [pendingSongToPlay, setPendingSongToPlay] = useState<{ platform: string; id: string } | null>(null); // 待播放的歌曲信息
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const lyricsContainerRef = useRef<HTMLDivElement>(null);
|
||||
const lastSaveTimeRef = useRef<number>(0);
|
||||
const restoredTimeRef = useRef<number>(0);
|
||||
const songStartTimeRef = useRef<number>(0); // 歌曲开始播放的时间戳
|
||||
|
||||
// 工具函数:处理图片 URL(在 HTTPS 环境下代理 HTTP 图片)
|
||||
const processImageUrl = (url: string | undefined, platform: string): string | undefined => {
|
||||
if (!url) return url;
|
||||
|
||||
const isHttps = typeof window !== 'undefined' && window.location.protocol === 'https:';
|
||||
|
||||
// 只对酷我音乐的 HTTP 图片在 HTTPS 环境下进行代理
|
||||
if (platform === 'kuwo' && isHttps && url.startsWith('http://')) {
|
||||
return `/api/music/proxy?url=${encodeURIComponent(url)}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
// 保存播放状态到 localStorage
|
||||
const savePlayState = () => {
|
||||
if (!currentSong) return;
|
||||
|
||||
const playState = {
|
||||
currentSong,
|
||||
currentSongIndex,
|
||||
songs,
|
||||
currentPlaylistTitle,
|
||||
currentSource,
|
||||
currentView,
|
||||
quality,
|
||||
playMode,
|
||||
volume,
|
||||
currentTime: audioRef.current?.currentTime || 0,
|
||||
currentSongUrl,
|
||||
lyrics,
|
||||
playRecords, // 只保存播放记录(平台+ID+播放信息)
|
||||
playlist, // 保存完整歌曲信息(用于显示)
|
||||
playlistIndex,
|
||||
};
|
||||
|
||||
localStorage.setItem('musicPlayState', JSON.stringify(playState));
|
||||
};
|
||||
|
||||
// 从 localStorage 恢复播放状态
|
||||
const restorePlayState = async () => {
|
||||
try {
|
||||
const saved = localStorage.getItem('musicPlayState');
|
||||
if (!saved) return;
|
||||
|
||||
const playState = JSON.parse(saved);
|
||||
|
||||
setCurrentSong(playState.currentSong);
|
||||
setCurrentSongIndex(playState.currentSongIndex);
|
||||
setSongs(playState.songs || []);
|
||||
setCurrentPlaylistTitle(playState.currentPlaylistTitle || '');
|
||||
setCurrentSource(playState.currentSource || 'netease');
|
||||
setCurrentView(playState.currentView || 'playlists');
|
||||
setQuality(playState.quality || '320k');
|
||||
setPlayMode(playState.playMode || 'loop');
|
||||
setVolume(playState.volume || 100);
|
||||
setLyrics(playState.lyrics || []);
|
||||
setPlayRecords(playState.playRecords || []);
|
||||
setPlaylist(playState.playlist || []); // 恢复播放列表
|
||||
|
||||
// 恢复 playlistIndex,如果没有则设置为 -1
|
||||
const restoredIndex = playState.playlistIndex ?? -1;
|
||||
setPlaylistIndex(restoredIndex);
|
||||
|
||||
// 保存需要恢复的时间点
|
||||
restoredTimeRef.current = playState.currentTime || 0;
|
||||
|
||||
if (playState.currentSong) {
|
||||
setShowPlayer(true);
|
||||
|
||||
// 记录歌曲开始播放的时间(恢复时也需要设置)
|
||||
songStartTimeRef.current = Date.now();
|
||||
|
||||
// 获取歌曲的平台信息
|
||||
const platform = playState.currentSong.platform || playState.currentSource || 'netease';
|
||||
|
||||
// 重新解析歌曲获取新的播放链接
|
||||
try {
|
||||
const response = await fetch('/api/music', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'parse',
|
||||
platform: platform,
|
||||
ids: playState.currentSong.id,
|
||||
quality: playState.quality || '320k',
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.code === 0 && data.data?.data && data.data.data.length > 0) {
|
||||
const songData = data.data.data[0];
|
||||
|
||||
if (songData.url && songData.success) {
|
||||
// 对于酷我音乐,使用代理
|
||||
let playUrl = songData.url;
|
||||
if (platform === 'kuwo') {
|
||||
playUrl = `/api/music/proxy?url=${encodeURIComponent(songData.url)}`;
|
||||
}
|
||||
|
||||
setCurrentSongUrl(songData.url);
|
||||
|
||||
// 延迟设置音频源,等待 audio 元素加载
|
||||
setTimeout(() => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.src = playUrl;
|
||||
|
||||
// 监听多个事件以确保进度恢复
|
||||
const restoreTime = () => {
|
||||
if (audioRef.current && restoredTimeRef.current > 0) {
|
||||
audioRef.current.currentTime = restoredTimeRef.current;
|
||||
restoredTimeRef.current = 0;
|
||||
}
|
||||
};
|
||||
|
||||
// 监听加载完成事件
|
||||
audioRef.current.addEventListener('loadedmetadata', restoreTime, { once: true });
|
||||
audioRef.current.addEventListener('canplay', restoreTime, { once: true });
|
||||
|
||||
// 调用 load() 触发音频加载
|
||||
audioRef.current.load();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重新解析歌曲失败:', error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('恢复播放状态失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 页面加载时恢复播放状态和数据库记录
|
||||
useEffect(() => {
|
||||
const initializePlayState = async () => {
|
||||
// 先恢复 localStorage 中的播放状态
|
||||
restorePlayState();
|
||||
|
||||
// 从数据库加载播放记录
|
||||
try {
|
||||
const dbRecords = await getAllMusicPlayRecords();
|
||||
|
||||
// 将数据库记录转换为前端格式
|
||||
const records: PlayRecord[] = [];
|
||||
const songs: Song[] = [];
|
||||
|
||||
Object.entries(dbRecords).forEach(([key, record]) => {
|
||||
records.push({
|
||||
platform: record.platform,
|
||||
id: record.id,
|
||||
playTime: record.play_time,
|
||||
duration: record.duration,
|
||||
timestamp: record.save_time,
|
||||
});
|
||||
|
||||
songs.push({
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
artist: record.artist,
|
||||
album: record.album,
|
||||
pic: record.pic,
|
||||
platform: record.platform, // 添加平台信息
|
||||
});
|
||||
});
|
||||
|
||||
// 更新状态
|
||||
if (records.length > 0) {
|
||||
setPlayRecords(records);
|
||||
setPlaylist(songs);
|
||||
|
||||
// 如果当前有正在播放的歌曲,找到它在记录中的索引
|
||||
const savedPlayState = localStorage.getItem('musicPlayState');
|
||||
if (savedPlayState) {
|
||||
const playState = JSON.parse(savedPlayState);
|
||||
if (playState.currentSong) {
|
||||
const platform = playState.currentSong.platform || playState.currentSource || 'netease';
|
||||
const currentIndex = records.findIndex(
|
||||
r => r.platform === platform && r.id === playState.currentSong.id
|
||||
);
|
||||
if (currentIndex >= 0) {
|
||||
setPlaylistIndex(currentIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载播放记录失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
initializePlayState();
|
||||
}, []);
|
||||
|
||||
// 监听播放状态变化,自动保存
|
||||
useEffect(() => {
|
||||
if (currentSong) {
|
||||
savePlayState();
|
||||
}
|
||||
}, [currentSong, currentSongIndex, songs, currentPlaylistTitle, currentSource, currentView, quality, playMode, volume, currentSongUrl, lyrics, playRecords, playlistIndex]);
|
||||
|
||||
// 监听 playRecords 变化,更新 playlistIndex
|
||||
useEffect(() => {
|
||||
if (pendingSongToPlay) {
|
||||
const index = playRecords.findIndex(
|
||||
r => r.platform === pendingSongToPlay.platform && r.id === pendingSongToPlay.id
|
||||
);
|
||||
setPlaylistIndex(index);
|
||||
setPendingSongToPlay(null);
|
||||
}
|
||||
}, [playRecords, pendingSongToPlay]);
|
||||
|
||||
// 加载排行榜列表
|
||||
const loadPlaylists = async (source: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/music?action=toplists&platform=${source}`
|
||||
);
|
||||
const data = await response.json();
|
||||
// 确保返回的是数组
|
||||
setPlaylists(Array.isArray(data) ? data : []);
|
||||
} catch (error) {
|
||||
console.error('加载排行榜失败:', error);
|
||||
setPlaylists([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 加载歌单详情
|
||||
const loadPlaylist = async (playlistId: string, playlistName: string) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/music?action=toplist&platform=${currentSource}&id=${playlistId}`
|
||||
);
|
||||
const data = await response.json();
|
||||
// 确保返回的是数组
|
||||
setSongs(Array.isArray(data) ? data : []);
|
||||
setCurrentPlaylistTitle(playlistName);
|
||||
setCurrentView('songs');
|
||||
} catch (error) {
|
||||
console.error('加载歌单失败:', error);
|
||||
setSongs([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 搜索歌曲
|
||||
const searchSongs = async () => {
|
||||
if (!searchKeyword.trim()) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/music?action=search&platform=${currentSource}&keyword=${encodeURIComponent(searchKeyword)}&page=1&pageSize=20`
|
||||
);
|
||||
const data = await response.json();
|
||||
// 确保返回的是数组
|
||||
setSongs(Array.isArray(data) ? data : []);
|
||||
setCurrentPlaylistTitle(`搜索: ${searchKeyword}`);
|
||||
setCurrentView('songs');
|
||||
} catch (error) {
|
||||
console.error('搜索失败:', error);
|
||||
setSongs([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 播放歌曲
|
||||
const playSong = async (song: Song, index: number) => {
|
||||
try {
|
||||
// 使用歌曲自己的平台信息,如果没有则使用当前选择的平台
|
||||
const platform = song.platform || currentSource;
|
||||
|
||||
// 记录歌曲开始播放的时间
|
||||
songStartTimeRef.current = Date.now();
|
||||
|
||||
// 先设置当前歌曲和显示播放器
|
||||
setCurrentSong(song);
|
||||
setCurrentSongIndex(index);
|
||||
setShowPlayer(true);
|
||||
setLyrics([]); // 清空旧歌词
|
||||
|
||||
// 添加到播放记录和播放列表
|
||||
const record: PlayRecord = {
|
||||
platform: platform,
|
||||
id: song.id,
|
||||
playTime: 0, // 初始播放时间
|
||||
duration: 0, // 将在音频加载后更新
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
// 设置待播放歌曲信息,用于在 playRecords 更新后找到索引
|
||||
setPendingSongToPlay({ platform, id: song.id });
|
||||
|
||||
setPlayRecords(prev => {
|
||||
const existingIndex = prev.findIndex(r => r.platform === record.platform && r.id === record.id);
|
||||
if (existingIndex >= 0) {
|
||||
// 记录已存在,更新时间戳
|
||||
const updated = [...prev];
|
||||
updated[existingIndex] = {
|
||||
...updated[existingIndex],
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
return updated;
|
||||
} else {
|
||||
// 新记录,添加到列表末尾
|
||||
return [...prev, record];
|
||||
}
|
||||
});
|
||||
|
||||
setPlaylist(prev => {
|
||||
const existingIndex = prev.findIndex(s => s.id === song.id && s.platform === platform);
|
||||
if (existingIndex >= 0) {
|
||||
return prev;
|
||||
} else {
|
||||
return [...prev, { ...song, platform }];
|
||||
}
|
||||
});
|
||||
|
||||
// 调用解析接口获取播放链接
|
||||
const response = await fetch('/api/music', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'parse',
|
||||
platform: platform, // 使用歌曲的平台
|
||||
ids: song.id,
|
||||
quality: quality,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// TuneHub 返回格式: { code: 0, data: { data: [...] } }
|
||||
if (data.code === 0 && data.data?.data && data.data.data.length > 0) {
|
||||
const songData = data.data.data[0];
|
||||
|
||||
if (songData.url && songData.success) {
|
||||
// 处理封面图片(在 HTTPS 环境下代理 HTTP 图片)
|
||||
const coverUrl = processImageUrl(songData.cover, platform);
|
||||
|
||||
// 更新歌曲信息,包括封面
|
||||
if (coverUrl) {
|
||||
setCurrentSong({
|
||||
...song,
|
||||
pic: coverUrl,
|
||||
platform,
|
||||
});
|
||||
}
|
||||
|
||||
// 解析歌词
|
||||
if (songData.lyrics) {
|
||||
const parsedLyrics = parseLyric(songData.lyrics);
|
||||
setLyrics(parsedLyrics);
|
||||
}
|
||||
|
||||
// 保存原始 URL 用于下载
|
||||
setCurrentSongUrl(songData.url);
|
||||
|
||||
// 对于酷我音乐,使用代理
|
||||
let playUrl = songData.url;
|
||||
if (platform === 'kuwo') {
|
||||
playUrl = `/api/music/proxy?url=${encodeURIComponent(songData.url)}`;
|
||||
}
|
||||
|
||||
if (audioRef.current) {
|
||||
audioRef.current.src = playUrl;
|
||||
audioRef.current.load();
|
||||
audioRef.current.play().catch(err => {
|
||||
console.error('播放失败:', err);
|
||||
});
|
||||
setIsPlaying(true);
|
||||
}
|
||||
} else {
|
||||
console.error('无法获取播放链接,songData:', songData);
|
||||
}
|
||||
} else {
|
||||
console.error('解析失败,完整响应:', data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('播放失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 解析歌词文本
|
||||
const parseLyric = (lyricText: string): LyricLine[] => {
|
||||
if (!lyricText) return [];
|
||||
|
||||
const lines = lyricText.split('\n');
|
||||
const lyricLines: LyricLine[] = [];
|
||||
|
||||
// 匹配 [mm:ss.xx] 或 [mm:ss] 格式
|
||||
const timeRegex = /\[(\d{2}):(\d{2})(?:\.(\d{2,3}))?\]/g;
|
||||
|
||||
lines.forEach(line => {
|
||||
const matches = Array.from(line.matchAll(timeRegex));
|
||||
if (matches.length > 0) {
|
||||
// 提取歌词文本(去掉所有时间标签)
|
||||
const text = line.replace(timeRegex, '').trim();
|
||||
if (text) {
|
||||
// 一行可能有多个时间标签
|
||||
matches.forEach(match => {
|
||||
const minutes = parseInt(match[1]);
|
||||
const seconds = parseInt(match[2]);
|
||||
const milliseconds = match[3] ? parseInt(match[3].padEnd(3, '0')) : 0;
|
||||
const time = minutes * 60 + seconds + milliseconds / 1000;
|
||||
lyricLines.push({ time, text });
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 按时间排序
|
||||
return lyricLines.sort((a, b) => a.time - b.time);
|
||||
};
|
||||
|
||||
// 切换播放/暂停
|
||||
const togglePlay = () => {
|
||||
if (audioRef.current) {
|
||||
if (isPlaying) {
|
||||
audioRef.current.pause();
|
||||
setIsPlaying(false);
|
||||
// 暂停时保存状态到 localStorage 和数据库
|
||||
savePlayState();
|
||||
|
||||
// 前5秒不保存(避免加载时的跳转触发保存)
|
||||
if (Date.now() - songStartTimeRef.current < 5000) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 保存到数据库
|
||||
if (currentSong && playlistIndex >= 0 && playRecords[playlistIndex]) {
|
||||
const record = playRecords[playlistIndex];
|
||||
const dbRecord: MusicPlayRecord = {
|
||||
platform: record.platform,
|
||||
id: record.id,
|
||||
name: currentSong.name,
|
||||
artist: currentSong.artist,
|
||||
album: currentSong.album,
|
||||
pic: currentSong.pic,
|
||||
play_time: audioRef.current.currentTime,
|
||||
duration: audioRef.current.duration || 0,
|
||||
save_time: Date.now(),
|
||||
};
|
||||
|
||||
saveMusicPlayRecord(record.platform, record.id, dbRecord).catch(err => {
|
||||
console.error('暂停时保存播放记录失败:', err);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
audioRef.current.play().catch(err => {
|
||||
console.error('播放失败:', err);
|
||||
});
|
||||
setIsPlaying(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 上一曲
|
||||
const playPrev = () => {
|
||||
// 优先从播放列表切换
|
||||
if (playlist.length > 0 && playlistIndex > 0) {
|
||||
const prevIndex = playlistIndex - 1;
|
||||
setPlaylistIndex(prevIndex);
|
||||
playSong(playlist[prevIndex], -1);
|
||||
} else if (currentSongIndex > 0) {
|
||||
playSong(songs[currentSongIndex - 1], currentSongIndex - 1);
|
||||
}
|
||||
};
|
||||
|
||||
// 下一曲
|
||||
const playNext = () => {
|
||||
// 优先从播放列表切换
|
||||
if (playlist.length > 0 && playlistIndex < playlist.length - 1) {
|
||||
const nextIndex = playlistIndex + 1;
|
||||
setPlaylistIndex(nextIndex);
|
||||
playSong(playlist[nextIndex], -1);
|
||||
} else if (currentSongIndex < songs.length - 1) {
|
||||
playSong(songs[currentSongIndex + 1], currentSongIndex + 1);
|
||||
}
|
||||
};
|
||||
|
||||
// 切换音质
|
||||
const cycleQuality = () => {
|
||||
const qualities: Array<'128k' | '320k' | 'flac' | 'flac24bit'> = ['128k', '320k', 'flac', 'flac24bit'];
|
||||
const currentIndex = qualities.indexOf(quality);
|
||||
const nextIndex = (currentIndex + 1) % qualities.length;
|
||||
setQuality(qualities[nextIndex]);
|
||||
};
|
||||
|
||||
// 切换播放模式
|
||||
const toggleMode = () => {
|
||||
const modes: Array<'loop' | 'single' | 'random'> = ['loop', 'single', 'random'];
|
||||
const currentIndex = modes.indexOf(playMode);
|
||||
const nextIndex = (currentIndex + 1) % modes.length;
|
||||
setPlayMode(modes[nextIndex]);
|
||||
};
|
||||
|
||||
// 返回
|
||||
const goBack = () => {
|
||||
if (currentView === 'songs') {
|
||||
setCurrentView('playlists');
|
||||
setSongs([]);
|
||||
} else {
|
||||
router.back();
|
||||
}
|
||||
};
|
||||
|
||||
// 下载歌曲
|
||||
const downloadSong = () => {
|
||||
if (!currentSongUrl || !currentSong) return;
|
||||
|
||||
// 创建一个临时的 a 标签来触发下载
|
||||
const link = document.createElement('a');
|
||||
link.href = currentSongUrl;
|
||||
link.download = `${currentSong.name} - ${currentSong.artist}.mp3`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
// 切换平台
|
||||
const switchSource = (source: 'netease' | 'qq' | 'kuwo') => {
|
||||
setCurrentSource(source);
|
||||
setCurrentView('playlists');
|
||||
setSongs([]);
|
||||
setSearchKeyword('');
|
||||
};
|
||||
|
||||
// 音频事件监听
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
setCurrentTime(audio.currentTime);
|
||||
|
||||
// 更新当前歌词索引
|
||||
if (lyrics.length > 0) {
|
||||
let index = -1;
|
||||
for (let i = 0; i < lyrics.length; i++) {
|
||||
if (lyrics[i].time <= audio.currentTime) {
|
||||
index = i;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
setCurrentLyricIndex(index);
|
||||
}
|
||||
|
||||
// 每20秒保存一次播放进度和播放时间
|
||||
const now = Date.now();
|
||||
if (now - lastSaveTimeRef.current > 20000) {
|
||||
lastSaveTimeRef.current = now;
|
||||
|
||||
// 前5秒不保存(避免加载时的跳转触发保存)
|
||||
if (Date.now() - songStartTimeRef.current < 5000) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新当前播放记录的播放时间
|
||||
if (currentSong && playlistIndex >= 0) {
|
||||
setPlayRecords(prev => {
|
||||
const updated = [...prev];
|
||||
if (updated[playlistIndex]) {
|
||||
updated[playlistIndex] = {
|
||||
...updated[playlistIndex],
|
||||
playTime: audio.currentTime,
|
||||
};
|
||||
|
||||
// 保存到数据库
|
||||
const record = updated[playlistIndex];
|
||||
const dbRecord: MusicPlayRecord = {
|
||||
platform: record.platform,
|
||||
id: record.id,
|
||||
name: currentSong.name,
|
||||
artist: currentSong.artist,
|
||||
album: currentSong.album,
|
||||
pic: currentSong.pic,
|
||||
play_time: audio.currentTime,
|
||||
duration: audio.duration || 0,
|
||||
save_time: Date.now(),
|
||||
};
|
||||
|
||||
saveMusicPlayRecord(record.platform, record.id, dbRecord).catch(err => {
|
||||
console.error('保存播放记录到数据库失败:', err);
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
savePlayState();
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
// 恢复播放进度
|
||||
if (restoredTimeRef.current > 0) {
|
||||
audio.currentTime = restoredTimeRef.current;
|
||||
restoredTimeRef.current = 0; // 清除标记
|
||||
}
|
||||
};
|
||||
|
||||
const handleDurationChange = () => {
|
||||
setDuration(audio.duration);
|
||||
|
||||
// 前5秒不保存(避免加载时的跳转触发保存)
|
||||
if (Date.now() - songStartTimeRef.current < 5000) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新当前播放记录的总时长
|
||||
if (currentSong && playlistIndex >= 0) {
|
||||
setPlayRecords(prev => {
|
||||
const updated = [...prev];
|
||||
if (updated[playlistIndex]) {
|
||||
updated[playlistIndex] = {
|
||||
...updated[playlistIndex],
|
||||
duration: audio.duration,
|
||||
};
|
||||
|
||||
// 保存到数据库(包含时长信息)
|
||||
const record = updated[playlistIndex];
|
||||
const dbRecord: MusicPlayRecord = {
|
||||
platform: record.platform,
|
||||
id: record.id,
|
||||
name: currentSong.name,
|
||||
artist: currentSong.artist,
|
||||
album: currentSong.album,
|
||||
pic: currentSong.pic,
|
||||
play_time: record.playTime,
|
||||
duration: audio.duration,
|
||||
save_time: Date.now(),
|
||||
};
|
||||
|
||||
saveMusicPlayRecord(record.platform, record.id, dbRecord).catch(err => {
|
||||
console.error('保存播放记录到数据库失败:', err);
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
};
|
||||
const handleEnded = () => {
|
||||
if (playMode === 'single') {
|
||||
audio.currentTime = 0;
|
||||
audio.play();
|
||||
} else if (playMode === 'random') {
|
||||
const randomIndex = Math.floor(Math.random() * songs.length);
|
||||
playSong(songs[randomIndex], randomIndex);
|
||||
} else {
|
||||
playNext();
|
||||
}
|
||||
};
|
||||
|
||||
audio.addEventListener('timeupdate', handleTimeUpdate);
|
||||
audio.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
audio.addEventListener('durationchange', handleDurationChange);
|
||||
audio.addEventListener('ended', handleEnded);
|
||||
|
||||
return () => {
|
||||
audio.removeEventListener('timeupdate', handleTimeUpdate);
|
||||
audio.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
audio.removeEventListener('durationchange', handleDurationChange);
|
||||
audio.removeEventListener('ended', handleEnded);
|
||||
};
|
||||
}, [playMode, songs, currentSongIndex, lyrics, currentSong, playlistIndex, playRecords]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
loadPlaylists(currentSource);
|
||||
}, [currentSource]);
|
||||
|
||||
// 歌词自动滚动
|
||||
useEffect(() => {
|
||||
if (lyricsContainerRef.current && currentLyricIndex >= 0) {
|
||||
const container = lyricsContainerRef.current;
|
||||
const activeLine = container.querySelector(`[data-index="${currentLyricIndex}"]`);
|
||||
if (activeLine) {
|
||||
activeLine.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [currentLyricIndex]);
|
||||
|
||||
// 搜索框回车
|
||||
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
searchSongs();
|
||||
}
|
||||
};
|
||||
|
||||
// 进度条拖动
|
||||
const handleProgressChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newTime = (parseFloat(e.target.value) / 100) * duration;
|
||||
if (audioRef.current) {
|
||||
audioRef.current.currentTime = newTime;
|
||||
}
|
||||
};
|
||||
|
||||
// 音量调节
|
||||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newVolume = parseInt(e.target.value);
|
||||
setVolume(newVolume);
|
||||
if (audioRef.current) {
|
||||
audioRef.current.volume = newVolume / 100;
|
||||
}
|
||||
};
|
||||
|
||||
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
|
||||
const getQualityLabel = () => {
|
||||
switch (quality) {
|
||||
case '128k': return '标准';
|
||||
case '320k': return 'HQ';
|
||||
case 'flac': return 'SQ';
|
||||
case 'flac24bit': return 'HR';
|
||||
}
|
||||
};
|
||||
|
||||
const getSourceLabel = () => {
|
||||
switch (currentSource) {
|
||||
case 'netease': return '网易云';
|
||||
case 'qq': return 'QQ音乐';
|
||||
case 'kuwo': return '酷我';
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (seconds: number) => {
|
||||
if (isNaN(seconds) || seconds === 0) return '0:00';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 text-white">
|
||||
{/* Header */}
|
||||
<header className="fixed top-0 left-0 right-0 z-40 bg-zinc-950/95 backdrop-blur-md border-b border-white/10 px-4 md:px-6">
|
||||
<div className="w-full mx-auto flex flex-col md:flex-row md:items-center md:justify-between gap-3 md:gap-4 py-3">
|
||||
<div className="flex items-center justify-between md:justify-start md:gap-6 w-full md:w-auto">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => router.push('/')}
|
||||
className="w-8 h-8 rounded-full flex items-center justify-center bg-white/10 hover:bg-white/20 text-white transition-colors"
|
||||
title="返回首页"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center bg-white/10 text-green-500">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="font-bold text-lg text-white">音乐</span>
|
||||
</div>
|
||||
<div className="flex bg-white/5 rounded-lg p-1 gap-1 border border-white/5">
|
||||
<button
|
||||
onClick={() => switchSource('netease')}
|
||||
className={`px-3 py-1 md:px-4 rounded text-[10px] font-bold tracking-wider transition-all ${
|
||||
currentSource === 'netease'
|
||||
? 'bg-green-500 text-white border border-white/30 shadow-lg shadow-green-500/50'
|
||||
: 'text-zinc-400 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
NET
|
||||
</button>
|
||||
<button
|
||||
onClick={() => switchSource('qq')}
|
||||
className={`px-3 py-1 md:px-4 rounded text-[10px] font-bold tracking-wider transition-all ${
|
||||
currentSource === 'qq'
|
||||
? 'bg-green-500 text-white border border-white/30 shadow-lg shadow-green-500/50'
|
||||
: 'text-zinc-400 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
QQ
|
||||
</button>
|
||||
<button
|
||||
onClick={() => switchSource('kuwo')}
|
||||
className={`px-3 py-1 md:px-4 rounded text-[10px] font-bold tracking-wider transition-all ${
|
||||
currentSource === 'kuwo'
|
||||
? 'bg-green-500 text-white border border-white/30 shadow-lg shadow-green-500/50'
|
||||
: 'text-zinc-400 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
酷我
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center w-full md:flex-1 md:max-w-md md:ml-auto h-10 md:h-9 gap-2">
|
||||
{currentView === 'songs' && (
|
||||
<button
|
||||
onClick={goBack}
|
||||
className="w-10 h-full rounded-lg bg-white/10 hover:bg-white/20 flex items-center justify-center text-white border border-white/10"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2.5" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<div className="relative group w-full h-full">
|
||||
<div className="absolute inset-y-0 left-3 flex items-center pointer-events-none">
|
||||
<svg className="w-4 h-4 text-zinc-500 group-focus-within:text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={searchKeyword}
|
||||
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
className="w-full h-full bg-black/30 border border-white/10 rounded-lg pl-9 pr-4 text-sm text-white focus:outline-none focus:border-green-500 focus:ring-2 focus:ring-green-500/50 font-mono placeholder-zinc-500"
|
||||
placeholder="搜索歌曲或艺术家..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="pt-[120px] md:pt-[96px] pb-32 px-4 md:px-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{loading && (
|
||||
<div className="text-center text-zinc-500 py-8">加载中...</div>
|
||||
)}
|
||||
|
||||
{/* Playlists View */}
|
||||
{currentView === 'playlists' && !loading && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6 border-b border-white/5 pb-2">
|
||||
<h2 className="text-xs font-mono text-white/50 tracking-widest">排行榜</h2>
|
||||
<span className="text-[10px] font-bold bg-white/10 px-2 py-0.5 rounded text-white">
|
||||
{getSourceLabel()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{playlists.map((playlist) => (
|
||||
<div
|
||||
key={playlist.id}
|
||||
onClick={() => loadPlaylist(playlist.id, playlist.name)}
|
||||
className="cursor-pointer group"
|
||||
>
|
||||
<div className="relative aspect-square rounded-lg overflow-hidden mb-2 bg-white/5">
|
||||
{playlist.pic && (
|
||||
<img
|
||||
src={playlist.pic}
|
||||
alt={playlist.name}
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-300"
|
||||
/>
|
||||
)}
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<svg className="w-12 h-12 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-sm font-medium text-white/80 truncate">{playlist.name}</h3>
|
||||
{playlist.updateFrequency && (
|
||||
<p className="text-xs text-zinc-500 mt-1">{playlist.updateFrequency}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Songs View */}
|
||||
{currentView === 'songs' && !loading && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6 border-b border-white/5 pb-2">
|
||||
<h2 className="text-xl font-bold text-white/80 tracking-tight truncate max-w-md">
|
||||
{currentPlaylistTitle}
|
||||
</h2>
|
||||
<span className="text-[10px] font-bold bg-white/10 px-2 py-0.5 rounded text-white shrink-0">
|
||||
{songs.length} 首歌曲
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{songs.map((song, index) => (
|
||||
<div
|
||||
key={`${song.id}-${index}`}
|
||||
onClick={() => playSong(song, index)}
|
||||
className={`grid grid-cols-[40px_1fr_auto] md:grid-cols-[50px_2fr_1fr_auto] gap-2 px-3 py-3 rounded-lg cursor-pointer transition-all ${
|
||||
currentSongIndex === index
|
||||
? 'bg-white/12 border-l-2 border-green-500'
|
||||
: 'hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<div className="text-center text-zinc-500 text-sm">{index + 1}</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-white truncate">{song.name}</div>
|
||||
<div className="text-xs text-zinc-500 truncate md:hidden">{song.artist}</div>
|
||||
</div>
|
||||
<div className="hidden md:block text-sm text-zinc-400 truncate">{song.artist}</div>
|
||||
<div className="text-xs text-zinc-600">{getSourceLabel()}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Player */}
|
||||
{showPlayer && currentSong && (
|
||||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 w-[95%] max-w-3xl z-50">
|
||||
<div className="bg-zinc-900/95 backdrop-blur-md rounded-xl p-4 border border-white/10 shadow-2xl">
|
||||
{/* Progress Bar */}
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-white/10 rounded-t-xl overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-green-500 transition-all pointer-events-none"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={progress}
|
||||
onChange={handleProgressChange}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4 mt-2">
|
||||
{/* Song Info */}
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div
|
||||
className="w-12 h-12 rounded-lg bg-zinc-800 overflow-hidden shrink-0 flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => setShowLyrics(true)}
|
||||
>
|
||||
{currentSong.pic ? (
|
||||
<img
|
||||
src={currentSong.pic}
|
||||
alt={currentSong.name}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
// 图片加载失败时显示默认图标
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<svg className="w-6 h-6 text-zinc-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-bold text-white truncate">{currentSong.name}</div>
|
||||
<div className="text-xs text-zinc-500 truncate">{currentSong.artist}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-4">
|
||||
<button onClick={playPrev} className="text-zinc-500 hover:text-white transition-colors">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="w-10 h-10 rounded-full bg-green-500 text-white flex items-center justify-center hover:bg-green-600 transition-colors"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button onClick={playNext} className="text-zinc-500 hover:text-white transition-colors">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right Controls */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="hidden sm:flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
value={volume}
|
||||
onChange={handleVolumeChange}
|
||||
className="w-16 h-1 bg-white/10 rounded-full appearance-none cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={downloadSong}
|
||||
className="text-zinc-500 hover:text-white transition-colors"
|
||||
title="下载歌曲"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleMode}
|
||||
className="text-zinc-500 hover:text-white transition-colors"
|
||||
title={playMode === 'loop' ? '列表循环' : playMode === 'single' ? '单曲循环' : '随机播放'}
|
||||
>
|
||||
{playMode === 'loop' && (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
)}
|
||||
{playMode === 'single' && (
|
||||
<div className="relative w-4 h-4">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
<span className="absolute inset-0 flex items-center justify-center text-[8px] font-bold">1</span>
|
||||
</div>
|
||||
)}
|
||||
{playMode === 'random' && (
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audio Element */}
|
||||
<audio ref={audioRef} className="hidden" />
|
||||
|
||||
{/* Lyrics Modal */}
|
||||
{showLyrics && currentSong && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/90 backdrop-blur-sm z-[100] flex items-center justify-center p-4"
|
||||
onClick={(e) => {
|
||||
// 点击背景关闭音量条
|
||||
if (e.target === e.currentTarget) {
|
||||
setShowVolumeSlider(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-2xl h-[90vh] md:h-auto max-h-[90vh] bg-zinc-900/95 rounded-2xl overflow-hidden border border-white/10 shadow-2xl flex flex-col"
|
||||
onClick={() => setShowVolumeSlider(false)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="relative h-32 md:h-48 bg-gradient-to-b from-zinc-800 to-zinc-900 shrink-0">
|
||||
{currentSong.pic && (
|
||||
<div className="absolute inset-0">
|
||||
<img
|
||||
src={currentSong.pic}
|
||||
alt={currentSong.name}
|
||||
className="w-full h-full object-cover opacity-30 blur-xl"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative h-full flex flex-col items-center justify-center p-4 md:p-6">
|
||||
<button
|
||||
onClick={() => setShowLyrics(false)}
|
||||
className="absolute top-2 right-2 md:top-4 md:right-4 w-8 h-8 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="w-16 h-16 md:w-24 md:h-24 rounded-xl overflow-hidden shadow-2xl mb-2 md:mb-4">
|
||||
{currentSong.pic ? (
|
||||
<img
|
||||
src={currentSong.pic}
|
||||
alt={currentSong.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-zinc-800 flex items-center justify-center">
|
||||
<svg className="w-8 h-8 md:w-12 md:h-12 text-zinc-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="text-base md:text-xl font-bold text-white text-center mb-1 line-clamp-1">{currentSong.name}</h2>
|
||||
<p className="text-xs md:text-sm text-zinc-400 line-clamp-1">{currentSong.artist}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lyrics Content */}
|
||||
<div ref={lyricsContainerRef} className="flex-1 overflow-y-auto p-4 md:p-6">
|
||||
{lyrics.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{lyrics.map((line, index) => (
|
||||
<div
|
||||
key={index}
|
||||
data-index={index}
|
||||
className={`text-center transition-all duration-300 ${
|
||||
index === currentLyricIndex
|
||||
? 'text-white text-lg font-bold scale-110'
|
||||
: index === currentLyricIndex - 1 || index === currentLyricIndex + 1
|
||||
? 'text-zinc-400 text-base'
|
||||
: 'text-zinc-600 text-sm'
|
||||
}`}
|
||||
>
|
||||
{line.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center space-y-4">
|
||||
<p className="text-zinc-500 text-sm">暂无歌词</p>
|
||||
<p className="text-zinc-600 text-xs">纯音乐或歌词获取失败</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mini Player Controls */}
|
||||
<div className="border-t border-white/5 p-3 md:p-4 shrink-0">
|
||||
{/* 上排:播放控制按钮 */}
|
||||
<div className="flex items-center justify-center gap-4 md:gap-6 mb-2 md:mb-3">
|
||||
<button onClick={playPrev} className="text-zinc-500 hover:text-white transition-colors">
|
||||
<svg className="w-5 h-5 md:w-6 md:h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className="w-10 h-10 md:w-12 md:h-12 rounded-full bg-green-500 text-white flex items-center justify-center hover:bg-green-600 transition-colors"
|
||||
>
|
||||
{isPlaying ? (
|
||||
<svg className="w-4 h-4 md:w-5 md:h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4 md:w-5 md:h-5 ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
<button onClick={playNext} className="text-zinc-500 hover:text-white transition-colors">
|
||||
<svg className="w-5 h-5 md:w-6 md:h-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 下排:其他按钮(小一号) */}
|
||||
<div className="flex items-center justify-center gap-3 md:gap-4 mb-2 md:mb-3">
|
||||
<button
|
||||
onClick={() => setShowPlaylist(true)}
|
||||
className="text-zinc-500 hover:text-white transition-colors relative"
|
||||
title="播放列表"
|
||||
>
|
||||
<svg className="w-4 h-4 md:w-5 md:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
|
||||
</svg>
|
||||
{playlist.length > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full text-[8px] flex items-center justify-center font-bold">
|
||||
{playlist.length > 9 ? '9+' : playlist.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={downloadSong}
|
||||
className="text-zinc-500 hover:text-white transition-colors"
|
||||
title="下载歌曲"
|
||||
>
|
||||
<svg className="w-4 h-4 md:w-5 md:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowQualityMenu(true)}
|
||||
className="px-2 py-0.5 rounded border text-amber-400 border-amber-500/50 bg-amber-900/20 text-[9px] md:text-[10px] font-mono min-w-[32px] text-center hover:bg-amber-900/30 transition-colors"
|
||||
title="音质选择"
|
||||
>
|
||||
{getQualityLabel()}
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleMode}
|
||||
className="text-zinc-500 hover:text-white transition-colors"
|
||||
title={playMode === 'loop' ? '列表循环' : playMode === 'single' ? '单曲循环' : '随机播放'}
|
||||
>
|
||||
{playMode === 'loop' && (
|
||||
<svg className="w-4 h-4 md:w-5 md:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
)}
|
||||
{playMode === 'single' && (
|
||||
<div className="relative w-4 h-4 md:w-5 md:h-5">
|
||||
<svg className="w-4 h-4 md:w-5 md:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
<span className="absolute inset-0 flex items-center justify-center text-[7px] md:text-[8px] font-bold">1</span>
|
||||
</div>
|
||||
)}
|
||||
{playMode === 'random' && (
|
||||
<svg className="w-4 h-4 md:w-5 md:h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
{/* 音量控制 */}
|
||||
<div className="relative group">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowVolumeSlider(!showVolumeSlider);
|
||||
}}
|
||||
className="text-zinc-500 hover:text-white transition-colors"
|
||||
title="音量"
|
||||
>
|
||||
<svg className="w-4 h-4 md:w-5 md:h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 01-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 10a5.984 5.984 0 01-1.757 4.243 1 1 0 01-1.415-1.415A3.984 3.984 0 0013 10a3.983 3.983 0 00-1.172-2.828 1 1 0 010-1.415z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* 垂直音量条 - 桌面悬浮/移动端点击 */}
|
||||
<div
|
||||
className={`absolute bottom-full left-1/2 -translate-x-1/2 pb-2 transition-opacity md:opacity-0 md:group-hover:opacity-100 md:pointer-events-auto ${showVolumeSlider ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="bg-zinc-800/95 backdrop-blur-sm rounded-lg p-3 shadow-xl border border-white/10">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span className="text-xs text-zinc-400 font-mono">{volume}</span>
|
||||
<div className="h-24 w-1 bg-white/10 rounded-full relative">
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 bg-green-500 rounded-full transition-all pointer-events-none"
|
||||
style={{ height: `${volume}%` }}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={volume}
|
||||
onChange={handleVolumeChange}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer [writing-mode:bt-lr] [-webkit-appearance:slider-vertical]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-xs text-zinc-500">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<div className="flex-1 h-1 bg-white/10 rounded-full overflow-hidden relative">
|
||||
<div
|
||||
className="h-full bg-green-500 transition-all pointer-events-none"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={progress}
|
||||
onChange={handleProgressChange}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<span>{formatTime(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Playlist Modal */}
|
||||
{showPlaylist && (
|
||||
<div className="fixed inset-0 bg-black/90 backdrop-blur-sm z-[100] flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-2xl h-[90vh] md:h-auto max-h-[90vh] bg-zinc-900/95 rounded-2xl overflow-hidden border border-white/10 shadow-2xl flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="relative h-16 bg-gradient-to-b from-zinc-800 to-zinc-900 shrink-0 flex items-center justify-between px-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-bold text-white">播放列表</h2>
|
||||
<span className="text-xs text-zinc-500">({playlist.length})</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{playlist.length > 0 && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (confirm('确定要清空全部播放记录吗?')) {
|
||||
try {
|
||||
await clearAllMusicPlayRecords();
|
||||
setPlaylist([]);
|
||||
setPlayRecords([]);
|
||||
setPlaylistIndex(-1);
|
||||
} catch (error) {
|
||||
console.error('清空播放记录失败:', error);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="px-3 py-1 text-xs rounded-lg bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors border border-red-500/50"
|
||||
title="清空全部"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowPlaylist(false)}
|
||||
className="w-8 h-8 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Playlist */}
|
||||
<div className="flex-1 overflow-y-auto p-4 md:p-6">
|
||||
{playlist.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{playlist.map((song, index) => (
|
||||
<div
|
||||
key={`${song.id}-${index}`}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg transition-colors group ${
|
||||
index === playlistIndex
|
||||
? 'bg-green-500/20 border border-green-500/50'
|
||||
: 'bg-white/5 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
onClick={() => {
|
||||
setPlaylistIndex(index);
|
||||
playSong(song, -1);
|
||||
setShowPlaylist(false);
|
||||
}}
|
||||
className="flex items-center gap-3 flex-1 min-w-0 cursor-pointer"
|
||||
>
|
||||
<div className="w-12 h-12 rounded-lg bg-zinc-800 overflow-hidden shrink-0">
|
||||
{song.pic ? (
|
||||
<img
|
||||
src={song.pic}
|
||||
alt={song.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-zinc-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className={`text-sm font-medium truncate transition-colors ${
|
||||
index === playlistIndex ? 'text-green-400' : 'text-white group-hover:text-green-400'
|
||||
}`}>
|
||||
{song.name}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500 truncate">{song.artist}</div>
|
||||
</div>
|
||||
{index === playlistIndex ? (
|
||||
<svg className="w-5 h-5 text-green-400 shrink-0 animate-pulse" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5 text-zinc-600 group-hover:text-white transition-colors shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M6.3 2.841A1.5 1.5 0 004 4.11V15.89a1.5 1.5 0 002.3 1.269l9.344-5.89a1.5 1.5 0 000-2.538L6.3 2.84z" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const platform = song.platform || 'netease';
|
||||
await deleteMusicPlayRecord(platform, song.id);
|
||||
|
||||
// 更新本地状态
|
||||
const newPlaylist = playlist.filter((_, i) => i !== index);
|
||||
const newRecords = playRecords.filter((_, i) => i !== index);
|
||||
setPlaylist(newPlaylist);
|
||||
setPlayRecords(newRecords);
|
||||
|
||||
// 如果删除的是当前播放的歌曲,调整索引
|
||||
if (index === playlistIndex) {
|
||||
setPlaylistIndex(-1);
|
||||
} else if (index < playlistIndex) {
|
||||
setPlaylistIndex(playlistIndex - 1);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除播放记录失败:', error);
|
||||
}
|
||||
}}
|
||||
className="w-8 h-8 rounded-lg bg-red-500/20 hover:bg-red-500/30 flex items-center justify-center transition-colors opacity-0 group-hover:opacity-100 shrink-0"
|
||||
title="删除"
|
||||
>
|
||||
<svg className="w-4 h-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<svg className="w-16 h-16 text-zinc-700 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3" />
|
||||
</svg>
|
||||
<p className="text-zinc-500 text-sm">播放列表为空</p>
|
||||
<p className="text-zinc-600 text-xs mt-2">播放歌曲后会自动添加到列表</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quality Selection Menu */}
|
||||
{showQualityMenu && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[100] flex items-end justify-center"
|
||||
onClick={() => setShowQualityMenu(false)}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md bg-zinc-900 rounded-t-2xl border-t border-white/10 shadow-2xl animate-slide-up"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b border-white/10">
|
||||
<h3 className="text-lg font-bold text-white text-center">选择音质</h3>
|
||||
</div>
|
||||
|
||||
{/* Quality Options */}
|
||||
<div className="p-4 space-y-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setQuality('128k');
|
||||
setShowQualityMenu(false);
|
||||
}}
|
||||
className={`w-full p-4 rounded-lg flex items-center justify-between transition-colors ${
|
||||
quality === '128k'
|
||||
? 'bg-amber-500/20 border border-amber-500/50'
|
||||
: 'bg-white/5 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-2 h-2 rounded-full ${quality === '128k' ? 'bg-amber-400' : 'bg-zinc-600'}`} />
|
||||
<div className="text-left">
|
||||
<div className="text-white font-medium">标准音质</div>
|
||||
<div className="text-xs text-zinc-500">128kbps</div>
|
||||
</div>
|
||||
</div>
|
||||
{quality === '128k' && (
|
||||
<svg className="w-5 h-5 text-amber-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setQuality('320k');
|
||||
setShowQualityMenu(false);
|
||||
}}
|
||||
className={`w-full p-4 rounded-lg flex items-center justify-between transition-colors ${
|
||||
quality === '320k'
|
||||
? 'bg-amber-500/20 border border-amber-500/50'
|
||||
: 'bg-white/5 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-2 h-2 rounded-full ${quality === '320k' ? 'bg-amber-400' : 'bg-zinc-600'}`} />
|
||||
<div className="text-left">
|
||||
<div className="text-white font-medium">高品质 HQ</div>
|
||||
<div className="text-xs text-zinc-500">320kbps</div>
|
||||
</div>
|
||||
</div>
|
||||
{quality === '320k' && (
|
||||
<svg className="w-5 h-5 text-amber-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setQuality('flac');
|
||||
setShowQualityMenu(false);
|
||||
}}
|
||||
className={`w-full p-4 rounded-lg flex items-center justify-between transition-colors ${
|
||||
quality === 'flac'
|
||||
? 'bg-amber-500/20 border border-amber-500/50'
|
||||
: 'bg-white/5 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-2 h-2 rounded-full ${quality === 'flac' ? 'bg-amber-400' : 'bg-zinc-600'}`} />
|
||||
<div className="text-left">
|
||||
<div className="text-white font-medium">无损音质 SQ</div>
|
||||
<div className="text-xs text-zinc-500">FLAC</div>
|
||||
</div>
|
||||
</div>
|
||||
{quality === 'flac' && (
|
||||
<svg className="w-5 h-5 text-amber-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setQuality('flac24bit');
|
||||
setShowQualityMenu(false);
|
||||
}}
|
||||
className={`w-full p-4 rounded-lg flex items-center justify-between transition-colors ${
|
||||
quality === 'flac24bit'
|
||||
? 'bg-amber-500/20 border border-amber-500/50'
|
||||
: 'bg-white/5 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-2 h-2 rounded-full ${quality === 'flac24bit' ? 'bg-amber-400' : 'bg-zinc-600'}`} />
|
||||
<div className="text-left">
|
||||
<div className="text-white font-medium">Hi-Res音质 HR</div>
|
||||
<div className="text-xs text-zinc-500">FLAC 24bit</div>
|
||||
</div>
|
||||
</div>
|
||||
{quality === 'flac24bit' && (
|
||||
<svg className="w-5 h-5 text-amber-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Cancel Button */}
|
||||
<div className="p-4 pt-0">
|
||||
<button
|
||||
onClick={() => setShowQualityMenu(false)}
|
||||
className="w-full p-3 rounded-lg bg-white/5 hover:bg-white/10 text-white transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+22
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
'use client';
|
||||
|
||||
import { Bot, ChevronRight, Link as LinkIcon, ListVideo } from 'lucide-react';
|
||||
import { Bot, ChevronRight, Link as LinkIcon, ListVideo, Music } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
@@ -66,6 +66,7 @@ function HomeClient() {
|
||||
const [aiEnabled, setAiEnabled] = useState(false);
|
||||
const [aiDefaultMessageNoVideo, setAiDefaultMessageNoVideo] = useState('你好!我是MoonTVPlus的AI影视助手。想看什么电影或剧集?需要推荐吗?');
|
||||
const [sourceSearchEnabled, setSourceSearchEnabled] = useState(true);
|
||||
const [musicEnabled, setMusicEnabled] = useState(false);
|
||||
const [showDirectPlayDialog, setShowDirectPlayDialog] = useState(false);
|
||||
const [directPlayUrl, setDirectPlayUrl] = useState('');
|
||||
|
||||
@@ -148,6 +149,14 @@ function HomeClient() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 检查音乐功能是否启用
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const enabled = (window as any).RUNTIME_CONFIG?.TUNEHUB_ENABLED === true;
|
||||
setMusicEnabled(enabled);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 检查公告弹窗状态
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined' && announcement) {
|
||||
@@ -593,6 +602,18 @@ function HomeClient() {
|
||||
<LinkIcon size={18} />
|
||||
</button>
|
||||
|
||||
{/* 音乐视听入口 */}
|
||||
{musicEnabled && (
|
||||
<Link href='/music'>
|
||||
<button
|
||||
className='p-2 rounded-lg text-green-500 hover:text-green-600 transition-colors'
|
||||
title='音乐视听'
|
||||
>
|
||||
<Music size={20} />
|
||||
</button>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* 源站寻片入口 */}
|
||||
{sourceSearchEnabled && (
|
||||
<Link href='/source-search'>
|
||||
|
||||
@@ -56,6 +56,10 @@ export interface AdminConfig {
|
||||
OIDCClientSecret?: string; // OIDC Client Secret
|
||||
OIDCButtonText?: string; // OIDC登录按钮文字
|
||||
OIDCMinTrustLevel?: number; // 最低信任等级(仅LinuxDo网站有效,为0时不判断)
|
||||
// TuneHub音乐配置
|
||||
TuneHubEnabled?: boolean; // 启用音乐功能
|
||||
TuneHubBaseUrl?: string; // TuneHub API地址
|
||||
TuneHubApiKey?: string; // TuneHub API Key
|
||||
};
|
||||
UserConfig: {
|
||||
Users: {
|
||||
|
||||
@@ -288,6 +288,123 @@ export class D1Storage implements IStorage {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 音乐播放记录相关 ====================
|
||||
|
||||
async getMusicPlayRecord(userName: string, key: string): Promise<any | null> {
|
||||
try {
|
||||
const result = await this.db
|
||||
.prepare('SELECT * FROM music_play_records WHERE username = ? AND key = ?')
|
||||
.bind(userName, key)
|
||||
.first();
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
return {
|
||||
platform: result.platform,
|
||||
id: result.song_id,
|
||||
name: result.name,
|
||||
artist: result.artist,
|
||||
album: result.album || undefined,
|
||||
pic: result.pic || undefined,
|
||||
play_time: result.play_time,
|
||||
duration: result.duration,
|
||||
save_time: result.save_time,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('D1Storage.getMusicPlayRecord error:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async setMusicPlayRecord(userName: string, key: string, record: any): Promise<void> {
|
||||
try {
|
||||
await this.db
|
||||
.prepare(`
|
||||
INSERT INTO music_play_records (username, key, platform, song_id, name, artist, album, pic, play_time, duration, save_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(username, key) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
artist = excluded.artist,
|
||||
album = excluded.album,
|
||||
pic = excluded.pic,
|
||||
play_time = excluded.play_time,
|
||||
duration = excluded.duration,
|
||||
save_time = excluded.save_time
|
||||
`)
|
||||
.bind(
|
||||
userName,
|
||||
key,
|
||||
record.platform,
|
||||
record.id,
|
||||
record.name,
|
||||
record.artist,
|
||||
record.album || null,
|
||||
record.pic || null,
|
||||
record.play_time,
|
||||
record.duration,
|
||||
record.save_time
|
||||
)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('D1Storage.setMusicPlayRecord error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async getAllMusicPlayRecords(userName: string): Promise<{ [key: string]: any }> {
|
||||
try {
|
||||
const results = await this.db
|
||||
.prepare('SELECT * FROM music_play_records WHERE username = ? ORDER BY save_time DESC')
|
||||
.bind(userName)
|
||||
.all();
|
||||
|
||||
const records: { [key: string]: any } = {};
|
||||
if (results.results) {
|
||||
for (const row of results.results) {
|
||||
records[row.key as string] = {
|
||||
platform: row.platform,
|
||||
id: row.song_id,
|
||||
name: row.name,
|
||||
artist: row.artist,
|
||||
album: row.album || undefined,
|
||||
pic: row.pic || undefined,
|
||||
play_time: row.play_time,
|
||||
duration: row.duration,
|
||||
save_time: row.save_time,
|
||||
};
|
||||
}
|
||||
}
|
||||
return records;
|
||||
} catch (err) {
|
||||
console.error('D1Storage.getAllMusicPlayRecords error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteMusicPlayRecord(userName: string, key: string): Promise<void> {
|
||||
try {
|
||||
await this.db
|
||||
.prepare('DELETE FROM music_play_records WHERE username = ? AND key = ?')
|
||||
.bind(userName, key)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('D1Storage.deleteMusicPlayRecord error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async clearAllMusicPlayRecords(userName: string): Promise<void> {
|
||||
try {
|
||||
await this.db
|
||||
.prepare('DELETE FROM music_play_records WHERE username = ?')
|
||||
.bind(userName)
|
||||
.run();
|
||||
} catch (err) {
|
||||
console.error('D1Storage.clearAllMusicPlayRecords error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 辅助方法 ====================
|
||||
|
||||
private rowToPlayRecord(row: any): PlayRecord {
|
||||
|
||||
@@ -57,6 +57,19 @@ export interface Favorite {
|
||||
vod_remarks?: string; // 视频备注信息
|
||||
}
|
||||
|
||||
// ---- 音乐播放记录类型 ----
|
||||
export interface MusicPlayRecord {
|
||||
platform: 'netease' | 'qq' | 'kuwo'; // 音乐平台
|
||||
id: string; // 歌曲ID
|
||||
name: string; // 歌曲名
|
||||
artist: string; // 艺术家
|
||||
album?: string; // 专辑
|
||||
pic?: string; // 封面图
|
||||
play_time: number; // 播放进度(秒)
|
||||
duration: number; // 总时长(秒)
|
||||
save_time: number; // 记录保存时间(时间戳)
|
||||
}
|
||||
|
||||
// ---- 缓存数据结构 ----
|
||||
interface CacheData<T> {
|
||||
data: T;
|
||||
@@ -70,12 +83,14 @@ interface UserCacheStore {
|
||||
searchHistory?: CacheData<string[]>;
|
||||
skipConfigs?: CacheData<Record<string, SkipConfig>>;
|
||||
danmakuFilterConfig?: CacheData<DanmakuFilterConfig>;
|
||||
musicPlayRecords?: CacheData<Record<string, MusicPlayRecord>>; // 音乐播放记录
|
||||
}
|
||||
|
||||
// ---- 常量 ----
|
||||
const PLAY_RECORDS_KEY = 'moontv_play_records';
|
||||
const FAVORITES_KEY = 'moontv_favorites';
|
||||
const SEARCH_HISTORY_KEY = 'moontv_search_history';
|
||||
const MUSIC_PLAY_RECORDS_KEY = 'moontv_music_play_records';
|
||||
|
||||
// 缓存相关常量
|
||||
const CACHE_PREFIX = 'moontv_cache_';
|
||||
@@ -398,6 +413,32 @@ class HybridCacheManager {
|
||||
this.saveUserCache(username, userCache);
|
||||
}
|
||||
|
||||
/**
|
||||
* 音乐播放记录缓存方法
|
||||
*/
|
||||
getCachedMusicPlayRecords(): Record<string, MusicPlayRecord> | null {
|
||||
const username = this.getCurrentUsername();
|
||||
if (!username) return null;
|
||||
|
||||
const userCache = this.getUserCache(username);
|
||||
const cached = userCache.musicPlayRecords;
|
||||
|
||||
if (cached && this.isCacheValid(cached)) {
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
cacheMusicPlayRecords(data: Record<string, MusicPlayRecord>): void {
|
||||
const username = this.getCurrentUsername();
|
||||
if (!username) return;
|
||||
|
||||
const userCache = this.getUserCache(username);
|
||||
userCache.musicPlayRecords = this.createCacheData(data);
|
||||
this.saveUserCache(username, userCache);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定用户的所有缓存
|
||||
*/
|
||||
@@ -1888,6 +1929,236 @@ export async function saveDanmakuFilterConfig(
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- 音乐播放记录相关 API ----------------
|
||||
|
||||
/**
|
||||
* 获取全部音乐播放记录。
|
||||
* 数据库存储模式下使用混合缓存策略:优先返回缓存数据,后台异步同步最新数据。
|
||||
*/
|
||||
export async function getAllMusicPlayRecords(): Promise<Record<string, MusicPlayRecord>> {
|
||||
// 服务器端渲染阶段直接返回空
|
||||
if (typeof window === 'undefined') {
|
||||
return {};
|
||||
}
|
||||
|
||||
// 数据库存储模式:使用混合缓存策略(包括 redis 和 upstash)
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
// 优先从缓存获取数据
|
||||
const cachedData = cacheManager.getCachedMusicPlayRecords();
|
||||
|
||||
if (cachedData) {
|
||||
// 返回缓存数据,同时后台异步更新
|
||||
fetchFromApi<Record<string, MusicPlayRecord>>(`/api/music/playrecords`)
|
||||
.then((freshData) => {
|
||||
// 只有数据真正不同时才更新缓存
|
||||
if (JSON.stringify(cachedData) !== JSON.stringify(freshData)) {
|
||||
cacheManager.cacheMusicPlayRecords(freshData);
|
||||
// 触发数据更新事件
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('musicPlayRecordsUpdated', {
|
||||
detail: freshData,
|
||||
})
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn('后台同步音乐播放记录失败:', err);
|
||||
triggerGlobalError('后台同步音乐播放记录失败');
|
||||
});
|
||||
|
||||
return cachedData;
|
||||
} else {
|
||||
// 缓存为空,直接从 API 获取并缓存
|
||||
try {
|
||||
const freshData = await fetchFromApi<Record<string, MusicPlayRecord>>(
|
||||
`/api/music/playrecords`
|
||||
);
|
||||
cacheManager.cacheMusicPlayRecords(freshData);
|
||||
return freshData;
|
||||
} catch (err) {
|
||||
console.error('获取音乐播放记录失败:', err);
|
||||
triggerGlobalError('获取音乐播放记录失败');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// localstorage 模式
|
||||
try {
|
||||
const raw = localStorage.getItem(MUSIC_PLAY_RECORDS_KEY);
|
||||
if (!raw) return {};
|
||||
return JSON.parse(raw) as Record<string, MusicPlayRecord>;
|
||||
} catch (err) {
|
||||
console.error('读取音乐播放记录失败:', err);
|
||||
triggerGlobalError('读取音乐播放记录失败');
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存音乐播放记录。
|
||||
* 数据库存储模式下使用乐观更新:先更新缓存(立即生效),再异步同步到数据库。
|
||||
*/
|
||||
export async function saveMusicPlayRecord(
|
||||
platform: string,
|
||||
id: string,
|
||||
record: MusicPlayRecord
|
||||
): Promise<void> {
|
||||
const key = generateStorageKey(platform, id);
|
||||
|
||||
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash)
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
// 立即更新缓存
|
||||
const cachedRecords = cacheManager.getCachedMusicPlayRecords() || {};
|
||||
cachedRecords[key] = record;
|
||||
cacheManager.cacheMusicPlayRecords(cachedRecords);
|
||||
|
||||
// 触发立即更新事件
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('musicPlayRecordsUpdated', {
|
||||
detail: cachedRecords,
|
||||
})
|
||||
);
|
||||
|
||||
// 异步同步到数据库
|
||||
try {
|
||||
await fetchWithAuth('/api/music/playrecords', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ key, record }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('保存音乐播放记录失败:', err);
|
||||
triggerGlobalError('保存音乐播放记录失败');
|
||||
throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// localstorage 模式
|
||||
if (typeof window === 'undefined') {
|
||||
console.warn('无法在服务端保存音乐播放记录到 localStorage');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const allRecords = await getAllMusicPlayRecords();
|
||||
allRecords[key] = record;
|
||||
localStorage.setItem(MUSIC_PLAY_RECORDS_KEY, JSON.stringify(allRecords));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('musicPlayRecordsUpdated', {
|
||||
detail: allRecords,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('保存音乐播放记录失败:', err);
|
||||
triggerGlobalError('保存音乐播放记录失败');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除音乐播放记录。
|
||||
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||
*/
|
||||
export async function deleteMusicPlayRecord(
|
||||
platform: string,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
const key = generateStorageKey(platform, id);
|
||||
|
||||
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash)
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
// 立即更新缓存
|
||||
const cachedRecords = cacheManager.getCachedMusicPlayRecords() || {};
|
||||
delete cachedRecords[key];
|
||||
cacheManager.cacheMusicPlayRecords(cachedRecords);
|
||||
|
||||
// 触发立即更新事件
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('musicPlayRecordsUpdated', {
|
||||
detail: cachedRecords,
|
||||
})
|
||||
);
|
||||
|
||||
// 异步同步到数据库
|
||||
try {
|
||||
await fetchWithAuth(`/api/music/playrecords?key=${encodeURIComponent(key)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('删除音乐播放记录失败:', err);
|
||||
triggerGlobalError('删除音乐播放记录失败');
|
||||
throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// localstorage 模式
|
||||
if (typeof window === 'undefined') {
|
||||
console.warn('无法在服务端删除音乐播放记录到 localStorage');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const allRecords = await getAllMusicPlayRecords();
|
||||
delete allRecords[key];
|
||||
localStorage.setItem(MUSIC_PLAY_RECORDS_KEY, JSON.stringify(allRecords));
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('musicPlayRecordsUpdated', {
|
||||
detail: allRecords,
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('删除音乐播放记录失败:', err);
|
||||
triggerGlobalError('删除音乐播放记录失败');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空全部音乐播放记录
|
||||
* 数据库存储模式下使用乐观更新:先更新缓存,再异步同步到数据库。
|
||||
*/
|
||||
export async function clearAllMusicPlayRecords(): Promise<void> {
|
||||
// 数据库存储模式:乐观更新策略(包括 redis 和 upstash)
|
||||
if (STORAGE_TYPE !== 'localstorage') {
|
||||
// 立即更新缓存
|
||||
cacheManager.cacheMusicPlayRecords({});
|
||||
|
||||
// 触发立即更新事件
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('musicPlayRecordsUpdated', {
|
||||
detail: {},
|
||||
})
|
||||
);
|
||||
|
||||
// 异步同步到数据库
|
||||
try {
|
||||
await fetchWithAuth(`/api/music/playrecords`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('清空音乐播放记录失败:', err);
|
||||
triggerGlobalError('清空音乐播放记录失败');
|
||||
throw err;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// localStorage 模式
|
||||
if (typeof window === 'undefined') return;
|
||||
localStorage.removeItem(MUSIC_PLAY_RECORDS_KEY);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('musicPlayRecordsUpdated', {
|
||||
detail: {},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- 集数过滤配置相关 API ----------------
|
||||
|
||||
/**
|
||||
|
||||
+32
-1
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable no-console, @typescript-eslint/no-explicit-any, @typescript-eslint/no-non-null-assertion */
|
||||
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { MusicPlayRecord } from './db.client';
|
||||
import { KvrocksStorage } from './kvrocks.db';
|
||||
import { RedisStorage } from './redis.db';
|
||||
import { DanmakuFilterConfig,Favorite, IStorage, PlayRecord, SkipConfig } from './types';
|
||||
@@ -199,7 +200,37 @@ export class DbManager {
|
||||
return favorite !== null;
|
||||
}
|
||||
|
||||
|
||||
// 音乐播放记录相关方法
|
||||
async saveMusicPlayRecord(
|
||||
userName: string,
|
||||
platform: string,
|
||||
id: string,
|
||||
record: MusicPlayRecord
|
||||
): Promise<void> {
|
||||
const key = generateStorageKey(platform, id);
|
||||
await this.storage.setMusicPlayRecord(userName, key, record);
|
||||
}
|
||||
|
||||
async getAllMusicPlayRecords(userName: string): Promise<{
|
||||
[key: string]: MusicPlayRecord;
|
||||
}> {
|
||||
return this.storage.getAllMusicPlayRecords(userName);
|
||||
}
|
||||
|
||||
async deleteMusicPlayRecord(
|
||||
userName: string,
|
||||
platform: string,
|
||||
id: string
|
||||
): Promise<void> {
|
||||
const key = generateStorageKey(platform, id);
|
||||
await this.storage.deleteMusicPlayRecord(userName, key);
|
||||
}
|
||||
|
||||
async clearAllMusicPlayRecords(userName: string): Promise<void> {
|
||||
await this.storage.clearAllMusicPlayRecords(userName);
|
||||
}
|
||||
|
||||
|
||||
async verifyUser(userName: string, password: string): Promise<boolean> {
|
||||
return this.storage.verifyUser(userName, password);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
// 音乐歌曲信息缓存模块 - 基于 platform+id 的全局缓存
|
||||
|
||||
// 歌曲信息接口
|
||||
export interface SongInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
artist: string;
|
||||
album?: string;
|
||||
pic?: string;
|
||||
}
|
||||
|
||||
// 缓存条目接口
|
||||
export interface SongCacheEntry {
|
||||
expiresAt: number;
|
||||
data: SongInfo;
|
||||
}
|
||||
|
||||
// 缓存配置
|
||||
const SONG_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24小时
|
||||
const CACHE_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1小时清理一次
|
||||
const MAX_CACHE_SIZE = 5000; // 最大缓存条目数量
|
||||
const SONG_CACHE: Map<string, SongCacheEntry> = new Map();
|
||||
|
||||
// 惰性清理时间戳
|
||||
let lastCleanupTime = 0;
|
||||
|
||||
/**
|
||||
* 生成歌曲缓存键:platform+id
|
||||
*/
|
||||
function makeSongCacheKey(platform: string, id: string): string {
|
||||
return `${platform}+${id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存的歌曲信息
|
||||
*/
|
||||
export function getCachedSong(platform: string, id: string): SongInfo | null {
|
||||
const key = makeSongCacheKey(platform, id);
|
||||
const entry = SONG_CACHE.get(key);
|
||||
if (!entry) return null;
|
||||
|
||||
// 检查是否过期
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
SONG_CACHE.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置缓存的歌曲信息
|
||||
*/
|
||||
export function setCachedSong(platform: string, id: string, songInfo: SongInfo): void {
|
||||
// 惰性清理:每次写入时检查是否需要清理
|
||||
const now = Date.now();
|
||||
if (now - lastCleanupTime > CACHE_CLEANUP_INTERVAL_MS) {
|
||||
performCacheCleanup();
|
||||
}
|
||||
|
||||
const key = makeSongCacheKey(platform, id);
|
||||
SONG_CACHE.set(key, {
|
||||
expiresAt: now + SONG_CACHE_TTL_MS,
|
||||
data: songInfo,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量获取缓存的歌曲信息
|
||||
*/
|
||||
export function getCachedSongs(keys: Array<{ platform: string; id: string }>): Map<string, SongInfo> {
|
||||
const result = new Map<string, SongInfo>();
|
||||
const now = Date.now();
|
||||
|
||||
for (const { platform, id } of keys) {
|
||||
const key = makeSongCacheKey(platform, id);
|
||||
const entry = SONG_CACHE.get(key);
|
||||
|
||||
if (entry && entry.expiresAt > now) {
|
||||
result.set(key, entry.data);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量设置缓存的歌曲信息
|
||||
*/
|
||||
export function setCachedSongs(songs: Array<{ platform: string; id: string; songInfo: SongInfo }>): void {
|
||||
const now = Date.now();
|
||||
|
||||
// 惰性清理
|
||||
if (now - lastCleanupTime > CACHE_CLEANUP_INTERVAL_MS) {
|
||||
performCacheCleanup();
|
||||
}
|
||||
|
||||
for (const { platform, id, songInfo } of songs) {
|
||||
const key = makeSongCacheKey(platform, id);
|
||||
SONG_CACHE.set(key, {
|
||||
expiresAt: now + SONG_CACHE_TTL_MS,
|
||||
data: songInfo,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能清理过期的缓存条目
|
||||
*/
|
||||
function performCacheCleanup(): { expired: number; total: number; sizeLimited: number } {
|
||||
const now = Date.now();
|
||||
const keysToDelete: string[] = [];
|
||||
let sizeLimitedDeleted = 0;
|
||||
|
||||
// 1. 清理过期条目
|
||||
SONG_CACHE.forEach((entry, key) => {
|
||||
if (entry.expiresAt <= now) {
|
||||
keysToDelete.push(key);
|
||||
}
|
||||
});
|
||||
|
||||
const expiredCount = keysToDelete.length;
|
||||
keysToDelete.forEach(key => SONG_CACHE.delete(key));
|
||||
|
||||
// 2. 如果缓存大小超限,清理最老的条目(LRU策略)
|
||||
if (SONG_CACHE.size > MAX_CACHE_SIZE) {
|
||||
const entries = Array.from(SONG_CACHE.entries());
|
||||
// 按照过期时间排序,最早过期的在前面
|
||||
entries.sort((a, b) => a[1].expiresAt - b[1].expiresAt);
|
||||
|
||||
const toRemove = SONG_CACHE.size - MAX_CACHE_SIZE;
|
||||
for (let i = 0; i < toRemove; i++) {
|
||||
SONG_CACHE.delete(entries[i][0]);
|
||||
sizeLimitedDeleted++;
|
||||
}
|
||||
}
|
||||
|
||||
lastCleanupTime = now;
|
||||
|
||||
return {
|
||||
expired: expiredCount,
|
||||
total: SONG_CACHE.size,
|
||||
sizeLimited: sizeLimitedDeleted
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有歌曲缓存
|
||||
*/
|
||||
export function clearSongCache(): { cleared: number } {
|
||||
const size = SONG_CACHE.size;
|
||||
SONG_CACHE.clear();
|
||||
return { cleared: size };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取缓存统计信息
|
||||
*/
|
||||
export function getSongCacheStats(): {
|
||||
size: number;
|
||||
maxSize: number;
|
||||
ttlMs: number;
|
||||
} {
|
||||
return {
|
||||
size: SONG_CACHE.size,
|
||||
maxSize: MAX_CACHE_SIZE,
|
||||
ttlMs: SONG_CACHE_TTL_MS,
|
||||
};
|
||||
}
|
||||
@@ -515,6 +515,54 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
console.log(`用户 ${userName} 的收藏迁移完成`);
|
||||
}
|
||||
|
||||
// ---------- 音乐播放记录相关 ----------
|
||||
private musicPlayRecordHashKey(userName: string) {
|
||||
return `u:${userName}:music_play_records`;
|
||||
}
|
||||
|
||||
async getMusicPlayRecord(userName: string, key: string): Promise<any | null> {
|
||||
const value = await this.withRetry(() =>
|
||||
this.adapter.hGet(this.musicPlayRecordHashKey(userName), key)
|
||||
);
|
||||
return value ? JSON.parse(value) : null;
|
||||
}
|
||||
|
||||
async setMusicPlayRecord(userName: string, key: string, record: any): Promise<void> {
|
||||
await this.withRetry(() =>
|
||||
this.adapter.hSet(
|
||||
this.musicPlayRecordHashKey(userName),
|
||||
key,
|
||||
JSON.stringify(record)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async getAllMusicPlayRecords(userName: string): Promise<Record<string, any>> {
|
||||
const hashData = await this.withRetry(() =>
|
||||
this.adapter.hGetAll(this.musicPlayRecordHashKey(userName))
|
||||
);
|
||||
|
||||
const result: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(hashData)) {
|
||||
if (value) {
|
||||
result[key] = JSON.parse(value);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async deleteMusicPlayRecord(userName: string, key: string): Promise<void> {
|
||||
await this.withRetry(() =>
|
||||
this.adapter.hDel(this.musicPlayRecordHashKey(userName), key)
|
||||
);
|
||||
}
|
||||
|
||||
async clearAllMusicPlayRecords(userName: string): Promise<void> {
|
||||
await this.withRetry(() =>
|
||||
this.adapter.del(this.musicPlayRecordHashKey(userName))
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 用户注册 / 登录(旧版本,保持兼容) ----------
|
||||
private userPwdKey(user: string) {
|
||||
return `u:${user}:pwd`;
|
||||
|
||||
@@ -52,6 +52,13 @@ export interface IStorage {
|
||||
// 迁移收藏
|
||||
migrateFavorites(userName: string): Promise<void>;
|
||||
|
||||
// 音乐播放记录相关
|
||||
getMusicPlayRecord(userName: string, key: string): Promise<any | null>;
|
||||
setMusicPlayRecord(userName: string, key: string, record: any): Promise<void>;
|
||||
getAllMusicPlayRecords(userName: string): Promise<{ [key: string]: any }>;
|
||||
deleteMusicPlayRecord(userName: string, key: string): Promise<void>;
|
||||
clearAllMusicPlayRecords(userName: string): Promise<void>;
|
||||
|
||||
// 用户相关
|
||||
verifyUser(userName: string, password: string): Promise<boolean>;
|
||||
// 检查用户是否存在(无需密码)
|
||||
|
||||
Reference in New Issue
Block a user