mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-29 03:17:22 +08:00
feat: 扩展数据层与播放能力
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
import request from '@/utils/request';
|
||||||
|
|
||||||
|
export const getNewAlbums = (params: { limit: number; offset: number; area: string }) => {
|
||||||
|
return request.get<any>('/album/new', { params });
|
||||||
|
};
|
||||||
@@ -1,444 +0,0 @@
|
|||||||
import type { IBilibiliPage, IBilibiliPlayUrl, IBilibiliVideoDetail } from '@/types/bilibili';
|
|
||||||
import type { SongResult } from '@/types/music';
|
|
||||||
import { getSetData, isElectron } from '@/utils';
|
|
||||||
import request from '@/utils/request';
|
|
||||||
|
|
||||||
interface ISearchParams {
|
|
||||||
keyword: string;
|
|
||||||
page?: number;
|
|
||||||
pagesize?: number;
|
|
||||||
search_type?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 搜索B站视频(带自动重试)
|
|
||||||
* 最多重试10次,每次间隔100ms
|
|
||||||
* @param params 搜索参数
|
|
||||||
*/
|
|
||||||
export const searchBilibili = async (params: ISearchParams): Promise<any> => {
|
|
||||||
console.log('调用B站搜索API,参数:', params);
|
|
||||||
const maxRetries = 10;
|
|
||||||
const delayMs = 100;
|
|
||||||
const delay = (ms: number) => new Promise((res) => setTimeout(res, ms));
|
|
||||||
|
|
||||||
let lastError: unknown = null;
|
|
||||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
||||||
try {
|
|
||||||
const response = await request.get('/bilibili/search', { params });
|
|
||||||
console.log('B站搜索API响应:', response);
|
|
||||||
const hasTitle = Boolean(response?.data?.data?.result?.length);
|
|
||||||
if (response?.status === 200 && hasTitle) {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastError = new Error(
|
|
||||||
`搜索结果不符合成功条件(缺少 data.title ) (attempt ${attempt}/${maxRetries})`
|
|
||||||
);
|
|
||||||
console.warn('B站搜索API响应不符合要求,将重试。调试信息:', {
|
|
||||||
status: response?.status,
|
|
||||||
hasData: Boolean(response?.data),
|
|
||||||
hasInnerData: Boolean(response?.data?.data),
|
|
||||||
title: response?.data?.data?.title
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
lastError = error;
|
|
||||||
console.warn(`B站搜索API错误[第${attempt}次],将重试:`, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attempt === maxRetries) {
|
|
||||||
console.error('B站搜索API重试达到上限,仍然失败');
|
|
||||||
if (lastError instanceof Error) throw lastError;
|
|
||||||
throw new Error('B站搜索失败且达到最大重试次数');
|
|
||||||
}
|
|
||||||
|
|
||||||
await delay(delayMs);
|
|
||||||
}
|
|
||||||
// 理论上不会到达这里,添加以满足TS控制流分析
|
|
||||||
throw new Error('B站搜索在重试后未返回有效结果');
|
|
||||||
};
|
|
||||||
|
|
||||||
interface IBilibiliResponse<T> {
|
|
||||||
code: number;
|
|
||||||
message: string;
|
|
||||||
ttl: number;
|
|
||||||
data: T;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取B站视频详情
|
|
||||||
* @param bvid B站视频BV号
|
|
||||||
* @returns 视频详情响应
|
|
||||||
*/
|
|
||||||
export const getBilibiliVideoDetail = (
|
|
||||||
bvid: string
|
|
||||||
): Promise<IBilibiliResponse<IBilibiliVideoDetail>> => {
|
|
||||||
console.log('调用B站视频详情API,bvid:', bvid);
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
request
|
|
||||||
.get('/bilibili/video/detail', {
|
|
||||||
params: { bvid }
|
|
||||||
})
|
|
||||||
.then((response) => {
|
|
||||||
console.log('B站视频详情API响应:', response.status);
|
|
||||||
|
|
||||||
// 检查响应状态和数据格式
|
|
||||||
if (response.status === 200 && response.data && response.data.data) {
|
|
||||||
console.log('B站视频详情API成功,标题:', response.data.data.title);
|
|
||||||
resolve(response.data);
|
|
||||||
} else {
|
|
||||||
console.error('B站视频详情API响应格式不正确:', response.data);
|
|
||||||
reject(new Error('获取视频详情响应格式不正确'));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('B站视频详情API错误:', error);
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取B站视频播放地址
|
|
||||||
* @param bvid B站视频BV号
|
|
||||||
* @param cid 视频分P的id
|
|
||||||
* @param qn 视频质量,默认为0
|
|
||||||
* @param fnval 视频格式标志,默认为80
|
|
||||||
* @param fnver 视频格式版本,默认为0
|
|
||||||
* @param fourk 是否允许4K视频,默认为1
|
|
||||||
* @returns 视频播放地址响应
|
|
||||||
*/
|
|
||||||
export const getBilibiliPlayUrl = (
|
|
||||||
bvid: string,
|
|
||||||
cid: number,
|
|
||||||
qn: number = 0,
|
|
||||||
fnval: number = 80,
|
|
||||||
fnver: number = 0,
|
|
||||||
fourk: number = 1
|
|
||||||
): Promise<IBilibiliResponse<IBilibiliPlayUrl>> => {
|
|
||||||
console.log('调用B站视频播放地址API,bvid:', bvid, 'cid:', cid);
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
request
|
|
||||||
.get('/bilibili/playurl', {
|
|
||||||
params: {
|
|
||||||
bvid,
|
|
||||||
cid,
|
|
||||||
qn,
|
|
||||||
fnval,
|
|
||||||
fnver,
|
|
||||||
fourk
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.then((response) => {
|
|
||||||
console.log('B站视频播放地址API响应:', response.status);
|
|
||||||
|
|
||||||
// 检查响应状态和数据格式
|
|
||||||
if (response.status === 200 && response.data && response.data.data) {
|
|
||||||
if (response.data.data.dash?.audio?.length > 0) {
|
|
||||||
console.log(
|
|
||||||
'B站视频播放地址API成功,获取到',
|
|
||||||
response.data.data.dash.audio.length,
|
|
||||||
'个音频地址'
|
|
||||||
);
|
|
||||||
} else if (response.data.data.durl?.length > 0) {
|
|
||||||
console.log(
|
|
||||||
'B站视频播放地址API成功,获取到',
|
|
||||||
response.data.data.durl.length,
|
|
||||||
'个播放地址'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
resolve(response.data);
|
|
||||||
} else {
|
|
||||||
console.error('B站视频播放地址API响应格式不正确:', response.data);
|
|
||||||
reject(new Error('获取视频播放地址响应格式不正确'));
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error('B站视频播放地址API错误:', error);
|
|
||||||
reject(error);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getBilibiliProxyUrl = (url: string) => {
|
|
||||||
const setData = getSetData();
|
|
||||||
const baseURL = isElectron
|
|
||||||
? `http://127.0.0.1:${setData?.musicApiPort}`
|
|
||||||
: import.meta.env.VITE_API;
|
|
||||||
const AUrl = url.startsWith('http') ? url : `https:${url}`;
|
|
||||||
return `${baseURL}/bilibili/stream-proxy?url=${encodeURIComponent(AUrl)}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getBilibiliAudioUrl = async (bvid: string, cid: number): Promise<string> => {
|
|
||||||
console.log('获取B站音频URL', { bvid, cid });
|
|
||||||
try {
|
|
||||||
const res = await getBilibiliPlayUrl(bvid, cid);
|
|
||||||
const playUrlData = res.data;
|
|
||||||
let url = '';
|
|
||||||
|
|
||||||
if (playUrlData.dash && playUrlData.dash.audio && playUrlData.dash.audio.length > 0) {
|
|
||||||
url = playUrlData.dash.audio[playUrlData.dash.audio.length - 1].baseUrl;
|
|
||||||
} else if (playUrlData.durl && playUrlData.durl.length > 0) {
|
|
||||||
url = playUrlData.durl[0].url;
|
|
||||||
} else {
|
|
||||||
throw new Error('未找到可用的音频地址');
|
|
||||||
}
|
|
||||||
|
|
||||||
return getBilibiliProxyUrl(url);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取B站音频URL失败:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 根据音乐名称搜索并直接返回音频URL
|
|
||||||
export const searchAndGetBilibiliAudioUrl = async (keyword: string): Promise<string> => {
|
|
||||||
try {
|
|
||||||
// 搜索B站视频,取第一页第一个结果
|
|
||||||
const res = await searchBilibili({ keyword, page: 1, pagesize: 1 });
|
|
||||||
if (!res) {
|
|
||||||
throw new Error('B站搜索返回为空');
|
|
||||||
}
|
|
||||||
const result = res.data?.data?.result;
|
|
||||||
if (!result || result.length === 0) {
|
|
||||||
throw new Error('未找到相关B站视频');
|
|
||||||
}
|
|
||||||
const first = result[0];
|
|
||||||
const bvid = first.bvid;
|
|
||||||
// 需要获取视频详情以获得cid
|
|
||||||
const detailRes = await getBilibiliVideoDetail(bvid);
|
|
||||||
const pages = detailRes.data.pages;
|
|
||||||
if (!pages || pages.length === 0) {
|
|
||||||
throw new Error('未找到视频分P信息');
|
|
||||||
}
|
|
||||||
const cid = pages[0].cid;
|
|
||||||
// 获取音频URL
|
|
||||||
return await getBilibiliAudioUrl(bvid, cid);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('根据名称搜索B站音频URL失败:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 解析B站ID格式
|
|
||||||
* @param biliId B站ID,可能是字符串格式(bvid--pid--cid)
|
|
||||||
* @returns 解析后的对象 {bvid, pid, cid} 或 null
|
|
||||||
*/
|
|
||||||
export const parseBilibiliId = (
|
|
||||||
biliId: string | number
|
|
||||||
): { bvid: string; pid: string; cid: number } | null => {
|
|
||||||
const strBiliId = String(biliId);
|
|
||||||
|
|
||||||
if (strBiliId.includes('--')) {
|
|
||||||
const [bvid, pid, cid] = strBiliId.split('--');
|
|
||||||
if (!bvid || !pid || !cid) {
|
|
||||||
console.warn(`B站ID格式错误: ${strBiliId}, 正确格式应为 bvid--pid--cid`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return { bvid, pid, cid: Number(cid) };
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建默认的Artist对象
|
|
||||||
* @param name 艺术家名称
|
|
||||||
* @param id 艺术家ID
|
|
||||||
* @returns Artist对象
|
|
||||||
*/
|
|
||||||
const createDefaultArtist = (name: string, id: number = 0) => ({
|
|
||||||
name,
|
|
||||||
id,
|
|
||||||
picId: 0,
|
|
||||||
img1v1Id: 0,
|
|
||||||
briefDesc: '',
|
|
||||||
img1v1Url: '',
|
|
||||||
albumSize: 0,
|
|
||||||
alias: [],
|
|
||||||
trans: '',
|
|
||||||
musicSize: 0,
|
|
||||||
topicPerson: 0,
|
|
||||||
picUrl: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建默认的Album对象
|
|
||||||
* @param name 专辑名称
|
|
||||||
* @param picUrl 专辑图片URL
|
|
||||||
* @param artistName 艺术家名称
|
|
||||||
* @param artistId 艺术家ID
|
|
||||||
* @returns Album对象
|
|
||||||
*/
|
|
||||||
const createDefaultAlbum = (
|
|
||||||
name: string,
|
|
||||||
picUrl: string,
|
|
||||||
artistName: string,
|
|
||||||
artistId: number = 0
|
|
||||||
) => ({
|
|
||||||
name,
|
|
||||||
picUrl,
|
|
||||||
id: 0,
|
|
||||||
type: '',
|
|
||||||
size: 0,
|
|
||||||
picId: 0,
|
|
||||||
blurPicUrl: '',
|
|
||||||
companyId: 0,
|
|
||||||
pic: 0,
|
|
||||||
publishTime: 0,
|
|
||||||
description: '',
|
|
||||||
tags: '',
|
|
||||||
company: '',
|
|
||||||
briefDesc: '',
|
|
||||||
artist: createDefaultArtist(artistName, artistId),
|
|
||||||
songs: [],
|
|
||||||
alias: [],
|
|
||||||
status: 0,
|
|
||||||
copyrightId: 0,
|
|
||||||
commentThreadId: '',
|
|
||||||
artists: [],
|
|
||||||
subType: '',
|
|
||||||
transName: null,
|
|
||||||
onSale: false,
|
|
||||||
mark: 0,
|
|
||||||
picId_str: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建基础的B站SongResult对象
|
|
||||||
* @param config 配置对象
|
|
||||||
* @returns SongResult对象
|
|
||||||
*/
|
|
||||||
const createBaseBilibiliSong = (config: {
|
|
||||||
id: string | number;
|
|
||||||
name: string;
|
|
||||||
picUrl: string;
|
|
||||||
artistName: string;
|
|
||||||
artistId?: number;
|
|
||||||
albumName: string;
|
|
||||||
bilibiliData?: { bvid: string; cid: number };
|
|
||||||
playMusicUrl?: string;
|
|
||||||
duration?: number;
|
|
||||||
}): SongResult => {
|
|
||||||
const {
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
picUrl,
|
|
||||||
artistName,
|
|
||||||
artistId = 0,
|
|
||||||
albumName,
|
|
||||||
bilibiliData,
|
|
||||||
playMusicUrl,
|
|
||||||
duration
|
|
||||||
} = config;
|
|
||||||
|
|
||||||
const baseResult: SongResult = {
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
picUrl,
|
|
||||||
ar: [createDefaultArtist(artistName, artistId)],
|
|
||||||
al: createDefaultAlbum(albumName, picUrl, artistName, artistId),
|
|
||||||
count: 0,
|
|
||||||
source: 'bilibili' as const
|
|
||||||
};
|
|
||||||
|
|
||||||
if (bilibiliData) {
|
|
||||||
baseResult.bilibiliData = bilibiliData;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playMusicUrl) {
|
|
||||||
baseResult.playMusicUrl = playMusicUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (duration !== undefined) {
|
|
||||||
baseResult.duration = duration;
|
|
||||||
}
|
|
||||||
|
|
||||||
return baseResult as SongResult;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 从B站视频详情和分P信息创建SongResult对象
|
|
||||||
* @param videoDetail B站视频详情
|
|
||||||
* @param page 分P信息
|
|
||||||
* @param bvid B站视频ID
|
|
||||||
* @returns SongResult对象
|
|
||||||
*/
|
|
||||||
export const createSongFromBilibiliVideo = (
|
|
||||||
videoDetail: IBilibiliVideoDetail,
|
|
||||||
page: IBilibiliPage,
|
|
||||||
bvid: string
|
|
||||||
): SongResult => {
|
|
||||||
const pageName = page.part || '';
|
|
||||||
const title = `${pageName} - ${videoDetail.title}`;
|
|
||||||
const songId = `${bvid}--${page.page}--${page.cid}`;
|
|
||||||
const picUrl = getBilibiliProxyUrl(videoDetail.pic);
|
|
||||||
|
|
||||||
return createBaseBilibiliSong({
|
|
||||||
id: songId,
|
|
||||||
name: title,
|
|
||||||
picUrl,
|
|
||||||
artistName: videoDetail.owner.name,
|
|
||||||
artistId: videoDetail.owner.mid,
|
|
||||||
albumName: videoDetail.title,
|
|
||||||
bilibiliData: {
|
|
||||||
bvid,
|
|
||||||
cid: page.cid
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建简化的SongResult对象(用于搜索结果直接播放)
|
|
||||||
* @param item 搜索结果项
|
|
||||||
* @param audioUrl 音频URL
|
|
||||||
* @returns SongResult对象
|
|
||||||
*/
|
|
||||||
export const createSimpleBilibiliSong = (item: any, audioUrl: string): SongResult => {
|
|
||||||
const duration = typeof item.duration === 'string' ? 0 : item.duration * 1000; // 转换为毫秒
|
|
||||||
|
|
||||||
return createBaseBilibiliSong({
|
|
||||||
id: item.id,
|
|
||||||
name: item.title,
|
|
||||||
picUrl: item.pic,
|
|
||||||
artistName: item.author,
|
|
||||||
albumName: item.title,
|
|
||||||
playMusicUrl: audioUrl,
|
|
||||||
duration
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 批量处理B站视频,从ID列表获取SongResult列表
|
|
||||||
* @param bilibiliIds B站ID列表
|
|
||||||
* @returns SongResult列表
|
|
||||||
*/
|
|
||||||
export const processBilibiliVideos = async (
|
|
||||||
bilibiliIds: (string | number)[]
|
|
||||||
): Promise<SongResult[]> => {
|
|
||||||
const bilibiliSongs: SongResult[] = [];
|
|
||||||
|
|
||||||
for (const biliId of bilibiliIds) {
|
|
||||||
const parsedId = parseBilibiliId(biliId);
|
|
||||||
if (!parsedId) continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await getBilibiliVideoDetail(parsedId.bvid);
|
|
||||||
const videoDetail = res.data;
|
|
||||||
|
|
||||||
// 找到对应的分P
|
|
||||||
const page = videoDetail.pages.find((p) => p.cid === parsedId.cid);
|
|
||||||
if (!page) {
|
|
||||||
console.warn(`未找到对应的分P: cid=${parsedId.cid}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const songData = createSongFromBilibiliVideo(videoDetail, page, parsedId.bvid);
|
|
||||||
bilibiliSongs.push(songData);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`获取B站视频详情失败 (${biliId}):`, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return bilibiliSongs;
|
|
||||||
};
|
|
||||||
@@ -50,3 +50,38 @@ export const getDayRecommend = () => {
|
|||||||
export const getNewAlbum = () => {
|
export const getNewAlbum = () => {
|
||||||
return request.get<IAlbumNew>('/album/newest');
|
return request.get<IAlbumNew>('/album/newest');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 获取轮播图
|
||||||
|
export const getBanners = (type: number = 0) => {
|
||||||
|
return request.get<any>('/banner', { params: { type } });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取推荐歌单
|
||||||
|
export const getPersonalizedPlaylist = (limit: number = 30) => {
|
||||||
|
return request.get<any>('/personalized', { params: { limit } });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取私人漫游
|
||||||
|
export const getPersonalFM = () => {
|
||||||
|
return request.get<any>('/personal_fm');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取独家放送
|
||||||
|
export const getPrivateContent = () => {
|
||||||
|
return request.get<any>('/personalized/privatecontent');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取推荐MV
|
||||||
|
export const getPersonalizedMV = () => {
|
||||||
|
return request.get<any>('/personalized/mv');
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取新碟上架
|
||||||
|
export const getTopAlbum = (params?: { limit?: number; offset?: number; area?: string }) => {
|
||||||
|
return request.get<any>('/top/album', { params });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取推荐电台
|
||||||
|
export const getPersonalizedDJ = () => {
|
||||||
|
return request.get<any>('/personalized/djprogram');
|
||||||
|
};
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import type { SongResult } from '@/types/music';
|
|||||||
import { isElectron } from '@/utils';
|
import { isElectron } from '@/utils';
|
||||||
import requestMusic from '@/utils/request_music';
|
import requestMusic from '@/utils/request_music';
|
||||||
|
|
||||||
import { searchAndGetBilibiliAudioUrl } from './bilibili';
|
|
||||||
import type { ParsedMusicResult } from './gdmusic';
|
import type { ParsedMusicResult } from './gdmusic';
|
||||||
import { parseFromGDMusic } from './gdmusic';
|
import { parseFromGDMusic } from './gdmusic';
|
||||||
import { LxMusicStrategy } from './lxMusicStrategy';
|
import { LxMusicStrategy } from './lxMusicStrategy';
|
||||||
@@ -164,7 +163,7 @@ export class CacheManager {
|
|||||||
console.log(`清除歌曲 ${id} 的URL缓存`);
|
console.log(`清除歌曲 ${id} 的URL缓存`);
|
||||||
|
|
||||||
// 清除失败缓存 - 需要遍历所有策略
|
// 清除失败缓存 - 需要遍历所有策略
|
||||||
const strategies = ['custom', 'bilibili', 'gdmusic', 'unblockMusic'];
|
const strategies = ['custom', 'gdmusic', 'unblockMusic'];
|
||||||
for (const strategy of strategies) {
|
for (const strategy of strategies) {
|
||||||
const cacheKey = `${id}_${strategy}`;
|
const cacheKey = `${id}_${strategy}`;
|
||||||
try {
|
try {
|
||||||
@@ -211,30 +210,6 @@ class RetryHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 从Bilibili获取音频URL
|
|
||||||
* @param data 歌曲数据
|
|
||||||
* @returns 解析结果
|
|
||||||
*/
|
|
||||||
const getBilibiliAudio = async (data: SongResult) => {
|
|
||||||
const songName = data?.name || '';
|
|
||||||
const artistName =
|
|
||||||
Array.isArray(data?.ar) && data.ar.length > 0 && data.ar[0]?.name ? data.ar[0].name : '';
|
|
||||||
const albumName = data?.al && typeof data.al === 'object' && data.al?.name ? data.al.name : '';
|
|
||||||
|
|
||||||
const searchQuery = [songName, artistName, albumName].filter(Boolean).join(' ').trim();
|
|
||||||
console.log('开始搜索bilibili音频:', searchQuery);
|
|
||||||
|
|
||||||
const url = await searchAndGetBilibiliAudioUrl(searchQuery);
|
|
||||||
return {
|
|
||||||
data: {
|
|
||||||
code: 200,
|
|
||||||
message: 'success',
|
|
||||||
data: { url }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从GD音乐台获取音频URL
|
* 从GD音乐台获取音频URL
|
||||||
* @param id 歌曲ID
|
* @param id 歌曲ID
|
||||||
@@ -363,46 +338,6 @@ class CustomApiStrategy implements MusicSourceStrategy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Bilibili解析策略
|
|
||||||
*/
|
|
||||||
class BilibiliStrategy implements MusicSourceStrategy {
|
|
||||||
name = 'bilibili';
|
|
||||||
priority = 2;
|
|
||||||
|
|
||||||
canHandle(sources: string[]): boolean {
|
|
||||||
return sources.includes('bilibili');
|
|
||||||
}
|
|
||||||
|
|
||||||
async parse(id: number, data: SongResult): Promise<MusicParseResult | null> {
|
|
||||||
// 检查失败缓存
|
|
||||||
if (CacheManager.isInFailedCache(id, this.name)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
console.log('尝试使用Bilibili解析...');
|
|
||||||
const result = await RetryHelper.withRetry(async () => {
|
|
||||||
return await getBilibiliAudio(data);
|
|
||||||
});
|
|
||||||
|
|
||||||
const adaptedResult = adaptParseResult(result);
|
|
||||||
if (adaptedResult?.data?.data?.url) {
|
|
||||||
console.log('Bilibili解析成功');
|
|
||||||
return adaptedResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 解析失败,添加失败缓存
|
|
||||||
CacheManager.addFailedCache(id, this.name);
|
|
||||||
return null;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Bilibili解析失败:', error);
|
|
||||||
CacheManager.addFailedCache(id, this.name);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GD音乐台解析策略
|
* GD音乐台解析策略
|
||||||
*/
|
*/
|
||||||
@@ -451,9 +386,7 @@ class UnblockMusicStrategy implements MusicSourceStrategy {
|
|||||||
priority = 4;
|
priority = 4;
|
||||||
|
|
||||||
canHandle(sources: string[]): boolean {
|
canHandle(sources: string[]): boolean {
|
||||||
const unblockSources = sources.filter(
|
const unblockSources = sources.filter((source) => !['custom', 'gdmusic'].includes(source));
|
||||||
(source) => !['custom', 'bilibili', 'gdmusic'].includes(source)
|
|
||||||
);
|
|
||||||
return unblockSources.length > 0;
|
return unblockSources.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -470,7 +403,7 @@ class UnblockMusicStrategy implements MusicSourceStrategy {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const unblockSources = (sources || []).filter(
|
const unblockSources = (sources || []).filter(
|
||||||
(source) => !['custom', 'bilibili', 'gdmusic'].includes(source)
|
(source) => !['custom', 'gdmusic'].includes(source)
|
||||||
);
|
);
|
||||||
console.log('尝试使用UnblockMusic解析:', unblockSources);
|
console.log('尝试使用UnblockMusic解析:', unblockSources);
|
||||||
|
|
||||||
@@ -502,7 +435,6 @@ class MusicSourceStrategyFactory {
|
|||||||
private static strategies: MusicSourceStrategy[] = [
|
private static strategies: MusicSourceStrategy[] = [
|
||||||
new LxMusicStrategy(),
|
new LxMusicStrategy(),
|
||||||
new CustomApiStrategy(),
|
new CustomApiStrategy(),
|
||||||
new BilibiliStrategy(),
|
|
||||||
new GDMusicStrategy(),
|
new GDMusicStrategy(),
|
||||||
new UnblockMusicStrategy()
|
new UnblockMusicStrategy()
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type {
|
||||||
|
DjCategoryListResponse,
|
||||||
|
DjDetailResponse,
|
||||||
|
DjProgramDetailResponse,
|
||||||
|
DjProgramResponse,
|
||||||
|
DjRadioHotResponse,
|
||||||
|
DjRecommendResponse,
|
||||||
|
DjSublistResponse,
|
||||||
|
DjTodayPerferedResponse,
|
||||||
|
DjToplistResponse,
|
||||||
|
PersonalizedDjProgramResponse,
|
||||||
|
RecentDjResponse
|
||||||
|
} from '@/types/podcast';
|
||||||
|
import request from '@/utils/request';
|
||||||
|
|
||||||
|
export const subscribeDj = (rid: number, t: 1 | 0) => {
|
||||||
|
return request.get('/dj/sub', { params: { rid, t } });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDjSublist = () => {
|
||||||
|
return request.get<DjSublistResponse>('/dj/sublist');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDjDetail = (rid: number) => {
|
||||||
|
return request.get<DjDetailResponse>('/dj/detail', { params: { rid } });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDjProgram = (rid: number, limit = 30, offset = 0, asc = false) => {
|
||||||
|
return request.get<DjProgramResponse>('/dj/program', {
|
||||||
|
params: { rid, limit, offset, asc }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDjProgramDetail = (id: number) => {
|
||||||
|
return request.get<DjProgramDetailResponse>('/dj/program/detail', { params: { id } });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDjRecommend = () => {
|
||||||
|
return request.get<DjRecommendResponse>('/dj/recommend');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDjCategoryList = () => {
|
||||||
|
return request.get<DjCategoryListResponse>('/dj/catelist');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDjRecommendByType = (type: number) => {
|
||||||
|
return request.get<DjRecommendResponse>('/dj/recommend/type', { params: { type } });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDjCategoryRecommend = () => {
|
||||||
|
return request.get('/dj/category/recommend');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDjTodayPerfered = () => {
|
||||||
|
return request.get<DjTodayPerferedResponse>('/dj/today/perfered');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDjPersonalizeRecommend = (limit = 5) => {
|
||||||
|
return request.get<DjTodayPerferedResponse>('/dj/personalize/recommend', { params: { limit } });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDjBanner = () => {
|
||||||
|
return request.get('/dj/banner');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPersonalizedDjProgram = () => {
|
||||||
|
return request.get<PersonalizedDjProgramResponse>('/personalized/djprogram');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDjToplist = (type: 'new' | 'hot', limit = 100) => {
|
||||||
|
return request.get<DjToplistResponse>('/dj/toplist', { params: { type, limit } });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDjRadioHot = (cateId: number, limit = 30, offset = 0) => {
|
||||||
|
return request.get<DjRadioHotResponse>('/dj/radio/hot', {
|
||||||
|
params: { cateId, limit, offset }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getRecentDj = () => {
|
||||||
|
return request.get<RecentDjResponse>('/record/recent/dj');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDjComment = (id: number, limit = 20, offset = 0) => {
|
||||||
|
return request.get('/comment/dj', { params: { id, limit, offset } });
|
||||||
|
};
|
||||||
@@ -39,8 +39,8 @@ export const SEARCH_TYPES = [
|
|||||||
key: 1004
|
key: 1004
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'search.search.bilibili', // B站
|
label: 'search.search.djradio', // 电台
|
||||||
key: 2000
|
key: 1009
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -50,5 +50,5 @@ export const SEARCH_TYPE = {
|
|||||||
ARTIST: 100, // 歌手
|
ARTIST: 100, // 歌手
|
||||||
PLAYLIST: 1000, // 歌单
|
PLAYLIST: 1000, // 歌单
|
||||||
MV: 1004, // MV
|
MV: 1004, // MV
|
||||||
BILIBILI: 2000 // B站视频
|
DJ_RADIO: 1009 // 电台
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { useLocalStorage } from '@vueuse/core';
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import type { DjProgram } from '@/types/podcast';
|
||||||
|
|
||||||
|
export const usePodcastHistory = () => {
|
||||||
|
const podcastHistory = useLocalStorage<DjProgram[]>('podcastHistory', []);
|
||||||
|
|
||||||
|
const addPodcast = (program: DjProgram) => {
|
||||||
|
const index = podcastHistory.value.findIndex((item) => item.id === program.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
podcastHistory.value.unshift(podcastHistory.value.splice(index, 1)[0]);
|
||||||
|
} else {
|
||||||
|
podcastHistory.value.unshift(program);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (podcastHistory.value.length > 100) {
|
||||||
|
podcastHistory.value.pop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const delPodcast = (program: DjProgram) => {
|
||||||
|
const index = podcastHistory.value.findIndex((item) => item.id === program.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
podcastHistory.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearPodcastHistory = () => {
|
||||||
|
podcastHistory.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const podcastList = ref(podcastHistory.value);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => podcastHistory.value,
|
||||||
|
() => {
|
||||||
|
podcastList.value = podcastHistory.value;
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
podcastHistory,
|
||||||
|
podcastList,
|
||||||
|
addPodcast,
|
||||||
|
delPodcast,
|
||||||
|
clearPodcastHistory
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { useLocalStorage } from '@vueuse/core';
|
||||||
|
import { ref, watch } from 'vue';
|
||||||
|
|
||||||
|
export type PodcastRadioHistoryItem = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
picUrl: string;
|
||||||
|
desc?: string;
|
||||||
|
dj?: {
|
||||||
|
nickname: string;
|
||||||
|
userId: number;
|
||||||
|
};
|
||||||
|
count?: number;
|
||||||
|
lastPlayTime?: number;
|
||||||
|
type?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePodcastRadioHistory = () => {
|
||||||
|
const podcastRadioHistory = useLocalStorage<PodcastRadioHistoryItem[]>('podcastRadioHistory', []);
|
||||||
|
|
||||||
|
const addPodcastRadio = (radio: PodcastRadioHistoryItem) => {
|
||||||
|
const index = podcastRadioHistory.value.findIndex((item) => item.id === radio.id);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (index !== -1) {
|
||||||
|
const existing = podcastRadioHistory.value.splice(index, 1)[0];
|
||||||
|
existing.count = (existing.count || 0) + 1;
|
||||||
|
existing.lastPlayTime = now;
|
||||||
|
podcastRadioHistory.value.unshift(existing);
|
||||||
|
} else {
|
||||||
|
podcastRadioHistory.value.unshift({
|
||||||
|
...radio,
|
||||||
|
count: 1,
|
||||||
|
lastPlayTime: now
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (podcastRadioHistory.value.length > 100) {
|
||||||
|
podcastRadioHistory.value.pop();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const delPodcastRadio = (radio: PodcastRadioHistoryItem) => {
|
||||||
|
const index = podcastRadioHistory.value.findIndex((item) => item.id === radio.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
podcastRadioHistory.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const podcastRadioList = ref(podcastRadioHistory.value);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => podcastRadioHistory.value,
|
||||||
|
() => {
|
||||||
|
podcastRadioList.value = podcastRadioHistory.value;
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
podcastRadioHistory,
|
||||||
|
podcastRadioList,
|
||||||
|
addPodcastRadio,
|
||||||
|
delPodcastRadio
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const downloadList = ref<any[]>([]);
|
||||||
|
const isInitialized = ref(false);
|
||||||
|
|
||||||
|
export const useDownloadStatus = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const downloadingCount = computed(() => {
|
||||||
|
return downloadList.value.filter((item) => item.status === 'downloading').length;
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigateToDownloads = () => {
|
||||||
|
router.push('/downloads');
|
||||||
|
};
|
||||||
|
|
||||||
|
const initDownloadListeners = () => {
|
||||||
|
if (isInitialized.value) return;
|
||||||
|
|
||||||
|
if (!window.electron?.ipcRenderer) return;
|
||||||
|
|
||||||
|
window.electron.ipcRenderer.on('music-download-progress', (_, data) => {
|
||||||
|
const existingItem = downloadList.value.find((item) => item.filename === data.filename);
|
||||||
|
|
||||||
|
if (data.progress === 100) {
|
||||||
|
data.status = 'completed';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingItem) {
|
||||||
|
Object.assign(existingItem, {
|
||||||
|
...data,
|
||||||
|
songInfo: data.songInfo || existingItem.songInfo
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.status === 'completed') {
|
||||||
|
downloadList.value = downloadList.value.filter((item) => item.filename !== data.filename);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
downloadList.value.push({
|
||||||
|
...data,
|
||||||
|
songInfo: data.songInfo
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.electron.ipcRenderer.on('music-download-complete', async (_, data) => {
|
||||||
|
if (data.success) {
|
||||||
|
downloadList.value = downloadList.value.filter((item) => item.filename !== data.filename);
|
||||||
|
} else {
|
||||||
|
const existingItem = downloadList.value.find((item) => item.filename === data.filename);
|
||||||
|
if (existingItem) {
|
||||||
|
Object.assign(existingItem, {
|
||||||
|
status: 'error',
|
||||||
|
error: data.error,
|
||||||
|
progress: 0
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
downloadList.value = downloadList.value.filter(
|
||||||
|
(item) => item.filename !== data.filename
|
||||||
|
);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.electron.ipcRenderer.on('music-download-queued', (_, data) => {
|
||||||
|
const existingItem = downloadList.value.find((item) => item.filename === data.filename);
|
||||||
|
if (!existingItem) {
|
||||||
|
downloadList.value.push({
|
||||||
|
filename: data.filename,
|
||||||
|
progress: 0,
|
||||||
|
loaded: 0,
|
||||||
|
total: 0,
|
||||||
|
path: '',
|
||||||
|
status: 'downloading',
|
||||||
|
songInfo: data.songInfo
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
isInitialized.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initDownloadListeners();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
downloadList,
|
||||||
|
downloadingCount,
|
||||||
|
navigateToDownloads
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -2,7 +2,6 @@ import { cloneDeep } from 'lodash';
|
|||||||
import { createDiscreteApi } from 'naive-ui';
|
import { createDiscreteApi } from 'naive-ui';
|
||||||
|
|
||||||
import i18n from '@/../i18n/renderer';
|
import i18n from '@/../i18n/renderer';
|
||||||
import { getBilibiliAudioUrl } from '@/api/bilibili';
|
|
||||||
import { getMusicLrc, getMusicUrl, getParsingMusicUrl } from '@/api/music';
|
import { getMusicLrc, getMusicUrl, getParsingMusicUrl } from '@/api/music';
|
||||||
import { playbackRequestManager } from '@/services/playbackRequestManager';
|
import { playbackRequestManager } from '@/services/playbackRequestManager';
|
||||||
import { SongSourceConfigManager } from '@/services/SongSourceConfigManager';
|
import { SongSourceConfigManager } from '@/services/SongSourceConfigManager';
|
||||||
@@ -39,28 +38,6 @@ export const getSongUrl = async (
|
|||||||
return songData.playMusicUrl;
|
return songData.playMusicUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (songData.source === 'bilibili' && songData.bilibiliData) {
|
|
||||||
console.log('加载B站音频URL');
|
|
||||||
if (!songData.playMusicUrl && songData.bilibiliData.bvid && songData.bilibiliData.cid) {
|
|
||||||
try {
|
|
||||||
songData.playMusicUrl = await getBilibiliAudioUrl(
|
|
||||||
songData.bilibiliData.bvid,
|
|
||||||
songData.bilibiliData.cid
|
|
||||||
);
|
|
||||||
// 验证请求
|
|
||||||
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
|
|
||||||
console.log(`[getSongUrl] 获取B站URL后请求已失效: ${requestId}`);
|
|
||||||
throw new Error('Request cancelled');
|
|
||||||
}
|
|
||||||
return songData.playMusicUrl;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('重启后获取B站音频URL失败:', error);
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return songData.playMusicUrl || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 自定义API最优先 ====================
|
// ==================== 自定义API最优先 ====================
|
||||||
const globalSources = settingsStore.setData.enabledMusicSources || [];
|
const globalSources = settingsStore.setData.enabledMusicSources || [];
|
||||||
const useCustomApiGlobally = globalSources.includes('custom');
|
const useCustomApiGlobally = globalSources.includes('custom');
|
||||||
@@ -108,7 +85,7 @@ export const getSongUrl = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 如果有自定义音源设置,直接使用getParsingMusicUrl获取URL
|
// 如果有自定义音源设置,直接使用getParsingMusicUrl获取URL
|
||||||
if (songConfig && songData.source !== 'bilibili') {
|
if (songConfig) {
|
||||||
try {
|
try {
|
||||||
console.log(`使用自定义音源解析歌曲 ID: ${id}`);
|
console.log(`使用自定义音源解析歌曲 ID: ${id}`);
|
||||||
const res = await getParsingMusicUrl(numericId, cloneDeep(songData));
|
const res = await getParsingMusicUrl(numericId, cloneDeep(songData));
|
||||||
@@ -239,15 +216,6 @@ const parseLyrics = (lyricsString: string): { lyrics: ILyricText[]; times: numbe
|
|||||||
* 加载歌词(独立函数)
|
* 加载歌词(独立函数)
|
||||||
*/
|
*/
|
||||||
export const loadLrc = async (id: string | number): Promise<ILyric> => {
|
export const loadLrc = async (id: string | number): Promise<ILyric> => {
|
||||||
if (typeof id === 'string' && id.includes('--')) {
|
|
||||||
console.log('B站音频,无需加载歌词');
|
|
||||||
return {
|
|
||||||
lrcTimeArray: [],
|
|
||||||
lrcArray: [],
|
|
||||||
hasWordByWord: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const numericId = typeof id === 'string' ? parseInt(id, 10) : id;
|
const numericId = typeof id === 'string' ? parseInt(id, 10) : id;
|
||||||
const { data } = await getMusicLrc(numericId);
|
const { data } = await getMusicLrc(numericId);
|
||||||
@@ -346,30 +314,6 @@ export const useSongDetail = () => {
|
|||||||
throw new Error('Request cancelled');
|
throw new Error('Request cancelled');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (playMusic.source === 'bilibili') {
|
|
||||||
try {
|
|
||||||
if (!playMusic.playMusicUrl && playMusic.bilibiliData) {
|
|
||||||
playMusic.playMusicUrl = await getBilibiliAudioUrl(
|
|
||||||
playMusic.bilibiliData.bvid,
|
|
||||||
playMusic.bilibiliData.cid
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证请求
|
|
||||||
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
|
|
||||||
console.log(`[getSongDetail] B站URL获取后请求已失效: ${requestId}`);
|
|
||||||
throw new Error('Request cancelled');
|
|
||||||
}
|
|
||||||
|
|
||||||
playMusic.playLoading = false;
|
|
||||||
return { ...playMusic } as SongResult;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取B站音频详情失败:', error);
|
|
||||||
playMusic.playLoading = false;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playMusic.expiredAt && playMusic.expiredAt < Date.now()) {
|
if (playMusic.expiredAt && playMusic.expiredAt < Date.now()) {
|
||||||
console.info(`歌曲已过期,重新获取: ${playMusic.name}`);
|
console.info(`歌曲已过期,重新获取: ${playMusic.name}`);
|
||||||
playMusic.playMusicUrl = undefined;
|
playMusic.playMusicUrl = undefined;
|
||||||
|
|||||||
@@ -32,6 +32,17 @@ const layoutRouter = [
|
|||||||
},
|
},
|
||||||
component: () => import('@/views/list/index.vue')
|
component: () => import('@/views/list/index.vue')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/album',
|
||||||
|
name: 'album',
|
||||||
|
meta: {
|
||||||
|
title: 'comp.newAlbum.title',
|
||||||
|
icon: 'ri-album-fill',
|
||||||
|
keepAlive: true,
|
||||||
|
isMobile: true
|
||||||
|
},
|
||||||
|
component: () => import('@/views/album/index.vue')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/toplist',
|
path: '/toplist',
|
||||||
name: 'toplist',
|
name: 'toplist',
|
||||||
@@ -77,6 +88,17 @@ const layoutRouter = [
|
|||||||
},
|
},
|
||||||
component: () => import('@/views/user/index.vue')
|
component: () => import('@/views/user/index.vue')
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/podcast',
|
||||||
|
name: 'podcast',
|
||||||
|
meta: {
|
||||||
|
title: 'podcast.podcast',
|
||||||
|
icon: 'ri-radio-fill',
|
||||||
|
keepAlive: true,
|
||||||
|
isMobile: true
|
||||||
|
},
|
||||||
|
component: () => import('@/views/podcast/index.vue')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/set',
|
path: '/set',
|
||||||
name: 'set',
|
name: 'set',
|
||||||
|
|||||||
@@ -55,17 +55,6 @@ const otherRouter = [
|
|||||||
},
|
},
|
||||||
component: () => import('@/views/artist/detail.vue')
|
component: () => import('@/views/artist/detail.vue')
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/bilibili/:bvid',
|
|
||||||
name: 'bilibiliPlayer',
|
|
||||||
meta: {
|
|
||||||
title: 'B站听书',
|
|
||||||
keepAlive: true,
|
|
||||||
showInMenu: false,
|
|
||||||
back: true
|
|
||||||
},
|
|
||||||
component: () => import('@/views/bilibili/BilibiliPlayer.vue')
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/music-list/:id?',
|
path: '/music-list/:id?',
|
||||||
name: 'musicList',
|
name: 'musicList',
|
||||||
@@ -130,6 +119,41 @@ const otherRouter = [
|
|||||||
back: true
|
back: true
|
||||||
},
|
},
|
||||||
component: () => import('@/views/mobile-search-result/index.vue')
|
component: () => import('@/views/mobile-search-result/index.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/podcast/radio/:id',
|
||||||
|
name: 'podcastRadio',
|
||||||
|
meta: {
|
||||||
|
title: 'podcast.radioDetail',
|
||||||
|
keepAlive: false,
|
||||||
|
showInMenu: false,
|
||||||
|
back: true,
|
||||||
|
isMobile: true
|
||||||
|
},
|
||||||
|
component: () => import('@/views/podcast/radio.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/podcast/category/:id',
|
||||||
|
name: 'podcastCategory',
|
||||||
|
meta: {
|
||||||
|
title: 'podcast.category',
|
||||||
|
keepAlive: false,
|
||||||
|
showInMenu: false,
|
||||||
|
back: true,
|
||||||
|
isMobile: true
|
||||||
|
},
|
||||||
|
component: () => import('@/views/podcast/category.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/search-result',
|
||||||
|
name: 'searchResult',
|
||||||
|
meta: {
|
||||||
|
title: '搜索结果',
|
||||||
|
keepAlive: true,
|
||||||
|
showInMenu: false,
|
||||||
|
back: true
|
||||||
|
},
|
||||||
|
component: () => import('@/views/search/SearchResult.vue')
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
export default otherRouter;
|
export default otherRouter;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Howl, Howler } from 'howler';
|
import { Howl, Howler } from 'howler';
|
||||||
|
|
||||||
|
import type { AudioOutputDevice } from '@/types/audio';
|
||||||
import type { SongResult } from '@/types/music';
|
import type { SongResult } from '@/types/music';
|
||||||
import { isElectron } from '@/utils'; // 导入isElectron常量
|
import { isElectron } from '@/utils'; // 导入isElectron常量
|
||||||
|
|
||||||
@@ -21,6 +22,10 @@ class AudioService {
|
|||||||
|
|
||||||
private playbackRate = 1.0; // 添加播放速度属性
|
private playbackRate = 1.0; // 添加播放速度属性
|
||||||
|
|
||||||
|
private currentSinkId: string = 'default';
|
||||||
|
|
||||||
|
private contextStateMonitoringInitialized = false;
|
||||||
|
|
||||||
// 预设的 EQ 频段
|
// 预设的 EQ 频段
|
||||||
private readonly frequencies = [31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
|
private readonly frequencies = [31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
|
||||||
|
|
||||||
@@ -304,6 +309,12 @@ class AudioService {
|
|||||||
await this.context.resume();
|
await this.context.resume();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 设置 AudioContext 状态监控
|
||||||
|
this.setupContextStateMonitoring();
|
||||||
|
|
||||||
|
// 恢复保存的音频输出设备
|
||||||
|
this.restoreSavedAudioDevice();
|
||||||
|
|
||||||
// 清理现有连接
|
// 清理现有连接
|
||||||
await this.disposeEQ(true);
|
await this.disposeEQ(true);
|
||||||
|
|
||||||
@@ -360,10 +371,24 @@ class AudioService {
|
|||||||
if (!this.source || !this.gainNode || !this.context) return;
|
if (!this.source || !this.gainNode || !this.context) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 断开所有现有连接
|
// 断开所有现有连接(捕获已断开的错误)
|
||||||
this.source.disconnect();
|
try {
|
||||||
this.filters.forEach((filter) => filter.disconnect());
|
this.source.disconnect();
|
||||||
this.gainNode.disconnect();
|
} catch {
|
||||||
|
/* already disconnected */
|
||||||
|
}
|
||||||
|
this.filters.forEach((filter) => {
|
||||||
|
try {
|
||||||
|
filter.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* already disconnected */
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
this.gainNode.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* already disconnected */
|
||||||
|
}
|
||||||
|
|
||||||
if (this.bypass) {
|
if (this.bypass) {
|
||||||
// EQ被禁用时,直接连接到输出
|
// EQ被禁用时,直接连接到输出
|
||||||
@@ -381,7 +406,17 @@ class AudioService {
|
|||||||
this.gainNode.connect(this.context.destination);
|
this.gainNode.connect(this.context.destination);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('应用EQ状态时出错:', error);
|
console.error('Error applying EQ state, attempting fallback:', error);
|
||||||
|
// Fallback: connect source directly to destination
|
||||||
|
try {
|
||||||
|
if (this.source && this.context) {
|
||||||
|
this.source.connect(this.context.destination);
|
||||||
|
console.log('Fallback: connected source directly to destination');
|
||||||
|
}
|
||||||
|
} catch (fallbackError) {
|
||||||
|
console.error('Fallback connection also failed:', fallbackError);
|
||||||
|
this.emit('audio_error', { type: 'graph_disconnected', error: fallbackError });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,6 +615,8 @@ class AudioService {
|
|||||||
this.context = Howler.ctx;
|
this.context = Howler.ctx;
|
||||||
Howler.masterGain = this.context.createGain();
|
Howler.masterGain = this.context.createGain();
|
||||||
Howler.masterGain.connect(this.context.destination);
|
Howler.masterGain.connect(this.context.destination);
|
||||||
|
// 重新创建上下文后恢复输出设备
|
||||||
|
this.restoreSavedAudioDevice();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 恢复上下文状态
|
// 恢复上下文状态
|
||||||
@@ -914,6 +951,137 @@ class AudioService {
|
|||||||
localStorage.setItem('currentPreset', preset);
|
localStorage.setItem('currentPreset', preset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 音频输出设备管理 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取可用的音频输出设备列表
|
||||||
|
*/
|
||||||
|
public async getAudioOutputDevices(): Promise<AudioOutputDevice[]> {
|
||||||
|
try {
|
||||||
|
// 先尝试获取一个临时音频流来触发权限授予
|
||||||
|
// 确保 enumerateDevices 返回完整的设备信息(包括 label)
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
|
} catch {
|
||||||
|
// 即使失败也继续,可能已有权限
|
||||||
|
}
|
||||||
|
|
||||||
|
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
const audioOutputs = devices.filter((d) => d.kind === 'audiooutput');
|
||||||
|
|
||||||
|
return audioOutputs.map((device, index) => ({
|
||||||
|
deviceId: device.deviceId,
|
||||||
|
label: device.label || `Speaker ${index + 1}`,
|
||||||
|
isDefault: device.deviceId === 'default' || device.deviceId === ''
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('枚举音频设备失败:', error);
|
||||||
|
return [{ deviceId: 'default', label: 'Default', isDefault: true }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置音频输出设备
|
||||||
|
* 使用 AudioContext.setSinkId() 而不是 HTMLMediaElement.setSinkId()
|
||||||
|
* 因为音频通过 MediaElementAudioSourceNode 进入 Web Audio 图后,
|
||||||
|
* HTMLMediaElement.setSinkId() 不再生效
|
||||||
|
*/
|
||||||
|
public async setAudioOutputDevice(deviceId: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
if (this.context && typeof (this.context as any).setSinkId === 'function') {
|
||||||
|
await (this.context as any).setSinkId(deviceId);
|
||||||
|
this.currentSinkId = deviceId;
|
||||||
|
localStorage.setItem('audioOutputDeviceId', deviceId);
|
||||||
|
console.log('音频输出设备已切换:', deviceId);
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.warn('AudioContext.setSinkId 不可用');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('设置音频输出设备失败:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前输出设备ID
|
||||||
|
*/
|
||||||
|
public getCurrentSinkId(): string {
|
||||||
|
return this.currentSinkId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 恢复保存的音频输出设备设置
|
||||||
|
*/
|
||||||
|
private async restoreSavedAudioDevice(): Promise<void> {
|
||||||
|
const savedDeviceId = localStorage.getItem('audioOutputDeviceId');
|
||||||
|
if (savedDeviceId && savedDeviceId !== 'default') {
|
||||||
|
try {
|
||||||
|
await this.setAudioOutputDevice(savedDeviceId);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('恢复音频输出设备失败,回退到默认设备:', error);
|
||||||
|
localStorage.removeItem('audioOutputDeviceId');
|
||||||
|
this.currentSinkId = 'default';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置 AudioContext 状态监控
|
||||||
|
* 监听上下文状态变化,自动恢复 suspended 状态
|
||||||
|
*/
|
||||||
|
private setupContextStateMonitoring() {
|
||||||
|
if (!this.context || this.contextStateMonitoringInitialized) return;
|
||||||
|
|
||||||
|
this.context.addEventListener('statechange', async () => {
|
||||||
|
console.log('AudioContext state changed:', this.context?.state);
|
||||||
|
|
||||||
|
if (this.context?.state === 'suspended' && this.currentSound?.playing()) {
|
||||||
|
console.log('AudioContext suspended while playing, attempting to resume...');
|
||||||
|
try {
|
||||||
|
await this.context.resume();
|
||||||
|
console.log('AudioContext resumed successfully');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to resume AudioContext:', e);
|
||||||
|
this.emit('audio_error', { type: 'context_suspended', error: e });
|
||||||
|
}
|
||||||
|
} else if (this.context?.state === 'closed') {
|
||||||
|
console.warn('AudioContext was closed unexpectedly');
|
||||||
|
this.emit('audio_error', { type: 'context_closed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.contextStateMonitoringInitialized = true;
|
||||||
|
console.log('AudioContext state monitoring initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证音频图是否正确连接
|
||||||
|
* 用于检测音频播放前的图状态
|
||||||
|
*/
|
||||||
|
private isAudioGraphConnected(): boolean {
|
||||||
|
if (!this.context || !this.gainNode || !this.source) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 检查 context 是否运行
|
||||||
|
if (this.context.state !== 'running') {
|
||||||
|
console.warn('AudioContext is not running, state:', this.context.state);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Web Audio API 不直接暴露连接状态,
|
||||||
|
// 但我们可以验证节点存在且 context 有效
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error checking audio graph:', e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public setPlaybackRate(rate: number) {
|
public setPlaybackRate(rate: number) {
|
||||||
if (!this.currentSound) return;
|
if (!this.currentSound) return;
|
||||||
this.playbackRate = rate;
|
this.playbackRate = rate;
|
||||||
@@ -986,12 +1154,14 @@ class AudioService {
|
|||||||
// 1. Howler API是否报告正在播放
|
// 1. Howler API是否报告正在播放
|
||||||
// 2. 是否不在加载状态
|
// 2. 是否不在加载状态
|
||||||
// 3. 确保音频上下文状态正常
|
// 3. 确保音频上下文状态正常
|
||||||
|
// 4. 确保音频图正确连接(在 Electron 环境中)
|
||||||
const isPlaying = this.currentSound.playing();
|
const isPlaying = this.currentSound.playing();
|
||||||
const isLoading = this.isLoading();
|
const isLoading = this.isLoading();
|
||||||
const contextRunning = Howler.ctx && Howler.ctx.state === 'running';
|
const contextRunning = Howler.ctx && Howler.ctx.state === 'running';
|
||||||
|
const graphConnected = isElectron ? this.isAudioGraphConnected() : true;
|
||||||
|
|
||||||
// 只有在三个条件都满足时才认为是真正在播放
|
// 只有在所有条件都满足时才认为是真正在播放
|
||||||
return isPlaying && !isLoading && contextRunning;
|
return isPlaying && !isLoading && contextRunning && graphConnected;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('检查播放状态出错:', error);
|
console.error('检查播放状态出错:', error);
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -164,7 +164,9 @@ export class EQService {
|
|||||||
if (node) {
|
if (node) {
|
||||||
node.disconnect();
|
node.disconnect();
|
||||||
// 特殊清理Tuna节点
|
// 特殊清理Tuna节点
|
||||||
if (node instanceof Tuna.Equalizer) node.destroy();
|
if (node === this.equalizer && typeof (node as any).destroy === 'function') {
|
||||||
|
(node as any).destroy();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -66,11 +66,7 @@ class PreloadService {
|
|||||||
|
|
||||||
// 时长差异只记录警告,不自动触发重新解析
|
// 时长差异只记录警告,不自动触发重新解析
|
||||||
// 用户可以通过 ReparsePopover 手动选择正确的音源
|
// 用户可以通过 ReparsePopover 手动选择正确的音源
|
||||||
if (
|
if (expectedDuration > 0 && Math.abs(duration - expectedDuration) > 5) {
|
||||||
expectedDuration > 0 &&
|
|
||||||
Math.abs(duration - expectedDuration) > 5 &&
|
|
||||||
song.source !== 'bilibili'
|
|
||||||
) {
|
|
||||||
console.warn(
|
console.warn(
|
||||||
`[PreloadService] 时长差异警告:实际 ${duration.toFixed(1)}s, 预期 ${expectedDuration.toFixed(1)}s (${song.name})`
|
`[PreloadService] 时长差异警告:实际 ${duration.toFixed(1)}s, 预期 ${expectedDuration.toFixed(1)}s (${song.name})`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,10 +15,14 @@ pinia.use(({ store }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 导出所有 store
|
// 导出所有 store
|
||||||
|
export * from './modules/intelligenceMode';
|
||||||
export * from './modules/lyric';
|
export * from './modules/lyric';
|
||||||
export * from './modules/menu';
|
export * from './modules/menu';
|
||||||
export * from './modules/music';
|
export * from './modules/music';
|
||||||
export * from './modules/player';
|
export * from './modules/player';
|
||||||
|
export * from './modules/playerCore';
|
||||||
|
export * from './modules/playlist';
|
||||||
|
export * from './modules/podcast';
|
||||||
export * from './modules/recommend';
|
export * from './modules/recommend';
|
||||||
export * from './modules/search';
|
export * from './modules/search';
|
||||||
export * from './modules/settings';
|
export * from './modules/settings';
|
||||||
|
|||||||
@@ -98,7 +98,6 @@ export const useIntelligenceModeStore = defineStore('intelligenceMode', () => {
|
|||||||
|
|
||||||
setLocalStorageItem('isIntelligenceMode', true);
|
setLocalStorageItem('isIntelligenceMode', true);
|
||||||
setLocalStorageItem('intelligenceModeInfo', intelligenceModeInfo.value);
|
setLocalStorageItem('intelligenceModeInfo', intelligenceModeInfo.value);
|
||||||
setLocalStorageItem('playMode', playlistStore.playMode);
|
|
||||||
|
|
||||||
// 替换播放列表并开始播放
|
// 替换播放列表并开始播放
|
||||||
playlistStore.setPlayList(intelligenceSongs, false, true);
|
playlistStore.setPlayList(intelligenceSongs, false, true);
|
||||||
@@ -114,12 +113,36 @@ export const useIntelligenceModeStore = defineStore('intelligenceMode', () => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 清除心动模式状态
|
* 清除心动模式状态
|
||||||
|
* @param skipPlayModeChange 是否跳过播放模式切换
|
||||||
*/
|
*/
|
||||||
const clearIntelligenceMode = () => {
|
const clearIntelligenceMode = (skipPlayModeChange: boolean = false) => {
|
||||||
|
console.log(
|
||||||
|
'[IntelligenceMode] clearIntelligenceMode 被调用,skipPlayModeChange:',
|
||||||
|
skipPlayModeChange
|
||||||
|
);
|
||||||
|
|
||||||
isIntelligenceMode.value = false;
|
isIntelligenceMode.value = false;
|
||||||
intelligenceModeInfo.value = null;
|
intelligenceModeInfo.value = null;
|
||||||
setLocalStorageItem('isIntelligenceMode', false);
|
setLocalStorageItem('isIntelligenceMode', false);
|
||||||
localStorage.removeItem('intelligenceModeInfo');
|
localStorage.removeItem('intelligenceModeInfo');
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
'[IntelligenceMode] 心动模式状态已清除,isIntelligenceMode:',
|
||||||
|
isIntelligenceMode.value
|
||||||
|
);
|
||||||
|
|
||||||
|
// 自动切换播放模式为顺序播放 (playMode = 0)
|
||||||
|
if (!skipPlayModeChange) {
|
||||||
|
(async () => {
|
||||||
|
const { usePlaylistStore } = await import('./playlist');
|
||||||
|
const playlistStore = usePlaylistStore();
|
||||||
|
|
||||||
|
if (playlistStore.playMode === 3) {
|
||||||
|
console.log('[IntelligenceMode] 退出心动模式,自动切换播放模式为顺序播放');
|
||||||
|
playlistStore.playMode = 0;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -24,6 +24,14 @@ export const useMusicStore = defineStore('music', {
|
|||||||
this.canRemoveSong = canRemove;
|
this.canRemoveSong = canRemove;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 仅设置基础信息(用于先导航后获取数据)
|
||||||
|
setBasicListInfo(name: string, listInfo: any = null, canRemove = false) {
|
||||||
|
this.currentMusicList = null; // 标识数据未加载
|
||||||
|
this.currentMusicListName = name;
|
||||||
|
this.currentListInfo = listInfo;
|
||||||
|
this.canRemoveSong = canRemove;
|
||||||
|
},
|
||||||
|
|
||||||
// 清除当前音乐列表
|
// 清除当前音乐列表
|
||||||
clearCurrentMusicList() {
|
clearCurrentMusicList() {
|
||||||
this.currentMusicList = null;
|
this.currentMusicList = null;
|
||||||
|
|||||||
@@ -4,19 +4,21 @@ import { defineStore } from 'pinia';
|
|||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
import i18n from '@/../i18n/renderer';
|
import i18n from '@/../i18n/renderer';
|
||||||
import { getBilibiliAudioUrl } from '@/api/bilibili';
|
|
||||||
import { getParsingMusicUrl } from '@/api/music';
|
import { getParsingMusicUrl } from '@/api/music';
|
||||||
import { useMusicHistory } from '@/hooks/MusicHistoryHook';
|
import { useMusicHistory } from '@/hooks/MusicHistoryHook';
|
||||||
|
import { usePodcastHistory } from '@/hooks/PodcastHistoryHook';
|
||||||
import { useLyrics, useSongDetail } from '@/hooks/usePlayerHooks';
|
import { useLyrics, useSongDetail } from '@/hooks/usePlayerHooks';
|
||||||
import { audioService } from '@/services/audioService';
|
import { audioService } from '@/services/audioService';
|
||||||
import { playbackRequestManager } from '@/services/playbackRequestManager';
|
import { playbackRequestManager } from '@/services/playbackRequestManager';
|
||||||
import { preloadService } from '@/services/preloadService';
|
import { preloadService } from '@/services/preloadService';
|
||||||
import { SongSourceConfigManager } from '@/services/SongSourceConfigManager';
|
import { SongSourceConfigManager } from '@/services/SongSourceConfigManager';
|
||||||
|
import type { AudioOutputDevice } from '@/types/audio';
|
||||||
import type { Platform, SongResult } from '@/types/music';
|
import type { Platform, SongResult } from '@/types/music';
|
||||||
import { getImgUrl } from '@/utils';
|
import { getImgUrl } from '@/utils';
|
||||||
import { getImageLinearBackground } from '@/utils/linearColor';
|
import { getImageLinearBackground } from '@/utils/linearColor';
|
||||||
|
|
||||||
const musicHistory = useMusicHistory();
|
const musicHistory = useMusicHistory();
|
||||||
|
const podcastHistory = usePodcastHistory();
|
||||||
const { message } = createDiscreteApi(['message']);
|
const { message } = createDiscreteApi(['message']);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,6 +38,12 @@ export const usePlayerCoreStore = defineStore(
|
|||||||
const volume = ref(1);
|
const volume = ref(1);
|
||||||
const userPlayIntent = ref(false); // 用户是否想要播放
|
const userPlayIntent = ref(false); // 用户是否想要播放
|
||||||
|
|
||||||
|
// 音频输出设备
|
||||||
|
const audioOutputDeviceId = ref<string>(
|
||||||
|
localStorage.getItem('audioOutputDeviceId') || 'default'
|
||||||
|
);
|
||||||
|
const availableAudioDevices = ref<AudioOutputDevice[]>([]);
|
||||||
|
|
||||||
let checkPlayTime: NodeJS.Timeout | null = null;
|
let checkPlayTime: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
// ==================== Computed ====================
|
// ==================== Computed ====================
|
||||||
@@ -239,14 +247,18 @@ export const usePlayerCoreStore = defineStore(
|
|||||||
(prev: string, curr: any) => `${prev}${curr.name}/`,
|
(prev: string, curr: any) => `${prev}${curr.name}/`,
|
||||||
''
|
''
|
||||||
)}`;
|
)}`;
|
||||||
} else if (music.source === 'bilibili' && music?.song?.ar?.[0]) {
|
|
||||||
title += ` - ${music.song.ar[0].name}`;
|
|
||||||
}
|
}
|
||||||
document.title = 'AlgerMusic - ' + title;
|
document.title = 'AlgerMusic - ' + title;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 添加到历史记录
|
// 添加到历史记录
|
||||||
musicHistory.addMusic(music);
|
if (music.isPodcast) {
|
||||||
|
if (music.program) {
|
||||||
|
podcastHistory.addPodcast(music.program);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
musicHistory.addMusic(music);
|
||||||
|
}
|
||||||
|
|
||||||
// 获取歌曲详情
|
// 获取歌曲详情
|
||||||
const updatedPlayMusic = await getSongDetail(originalMusic, requestId);
|
const updatedPlayMusic = await getSongDetail(originalMusic, requestId);
|
||||||
@@ -352,36 +364,6 @@ export const usePlayerCoreStore = defineStore(
|
|||||||
console.log('[playAudio] 恢复播放进度:', initialPosition);
|
console.log('[playAudio] 恢复播放进度:', initialPosition);
|
||||||
}
|
}
|
||||||
|
|
||||||
// B站视频URL检查
|
|
||||||
if (
|
|
||||||
playMusic.value.source === 'bilibili' &&
|
|
||||||
(!playMusicUrl.value || playMusicUrl.value === 'undefined')
|
|
||||||
) {
|
|
||||||
console.log('B站视频URL无效,尝试重新获取');
|
|
||||||
|
|
||||||
if (playMusic.value.bilibiliData) {
|
|
||||||
try {
|
|
||||||
const proxyUrl = await getBilibiliAudioUrl(
|
|
||||||
playMusic.value.bilibiliData.bvid,
|
|
||||||
playMusic.value.bilibiliData.cid
|
|
||||||
);
|
|
||||||
|
|
||||||
// 再次验证请求
|
|
||||||
if (requestId && !playbackRequestManager.isRequestValid(requestId)) {
|
|
||||||
console.log(`[playAudio] 获取B站URL后请求已失效: ${requestId}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
(playMusic.value as any).playMusicUrl = proxyUrl;
|
|
||||||
playMusicUrl.value = proxyUrl;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取B站音频URL失败:', error);
|
|
||||||
message.error(i18n.global.t('player.playFailed'));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用 PreloadService 获取音频
|
// 使用 PreloadService 获取音频
|
||||||
// 优先使用已预加载的 sound(通过 consume 获取并从缓存中移除)
|
// 优先使用已预加载的 sound(通过 consume 获取并从缓存中移除)
|
||||||
// 如果没有预加载,则进行加载
|
// 如果没有预加载,则进行加载
|
||||||
@@ -514,11 +496,6 @@ export const usePlayerCoreStore = defineStore(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentSong.source === 'bilibili') {
|
|
||||||
console.warn('B站视频不支持重新解析');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用 SongSourceConfigManager 保存配置
|
// 使用 SongSourceConfigManager 保存配置
|
||||||
SongSourceConfigManager.setConfig(
|
SongSourceConfigManager.setConfig(
|
||||||
currentSong.id,
|
currentSong.id,
|
||||||
@@ -579,11 +556,6 @@ export const usePlayerCoreStore = defineStore(
|
|||||||
console.log('恢复上次播放的音乐:', playMusic.value.name);
|
console.log('恢复上次播放的音乐:', playMusic.value.name);
|
||||||
const isPlaying = settingStore.setData.autoPlay;
|
const isPlaying = settingStore.setData.autoPlay;
|
||||||
|
|
||||||
if (playMusic.value.source === 'bilibili' && playMusic.value.bilibiliData) {
|
|
||||||
console.log('恢复B站视频播放', playMusic.value.bilibiliData);
|
|
||||||
playMusic.value.playMusicUrl = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
await handlePlayMusic(
|
await handlePlayMusic(
|
||||||
{ ...playMusic.value, isFirstPlay: true, playMusicUrl: undefined },
|
{ ...playMusic.value, isFirstPlay: true, playMusicUrl: undefined },
|
||||||
isPlaying
|
isPlaying
|
||||||
@@ -602,6 +574,43 @@ export const usePlayerCoreStore = defineStore(
|
|||||||
}, 2000);
|
}, 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ==================== 音频输出设备管理 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新可用音频输出设备列表
|
||||||
|
*/
|
||||||
|
const refreshAudioDevices = async () => {
|
||||||
|
availableAudioDevices.value = await audioService.getAudioOutputDevices();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 切换音频输出设备
|
||||||
|
*/
|
||||||
|
const setAudioOutputDevice = async (deviceId: string): Promise<boolean> => {
|
||||||
|
const success = await audioService.setAudioOutputDevice(deviceId);
|
||||||
|
if (success) {
|
||||||
|
audioOutputDeviceId.value = deviceId;
|
||||||
|
}
|
||||||
|
return success;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化设备变化监听
|
||||||
|
*/
|
||||||
|
const initAudioDeviceListener = () => {
|
||||||
|
if (navigator.mediaDevices) {
|
||||||
|
navigator.mediaDevices.addEventListener('devicechange', async () => {
|
||||||
|
await refreshAudioDevices();
|
||||||
|
const exists = availableAudioDevices.value.some(
|
||||||
|
(d) => d.deviceId === audioOutputDeviceId.value
|
||||||
|
);
|
||||||
|
if (!exists && audioOutputDeviceId.value !== 'default') {
|
||||||
|
await setAudioOutputDevice('default');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// 状态
|
// 状态
|
||||||
play,
|
play,
|
||||||
@@ -612,6 +621,8 @@ export const usePlayerCoreStore = defineStore(
|
|||||||
playbackRate,
|
playbackRate,
|
||||||
volume,
|
volume,
|
||||||
userPlayIntent,
|
userPlayIntent,
|
||||||
|
audioOutputDeviceId,
|
||||||
|
availableAudioDevices,
|
||||||
|
|
||||||
// Computed
|
// Computed
|
||||||
currentSong,
|
currentSong,
|
||||||
@@ -631,14 +642,17 @@ export const usePlayerCoreStore = defineStore(
|
|||||||
handlePause,
|
handlePause,
|
||||||
checkPlaybackState,
|
checkPlaybackState,
|
||||||
reparseCurrentSong,
|
reparseCurrentSong,
|
||||||
initializePlayState
|
initializePlayState,
|
||||||
|
refreshAudioDevices,
|
||||||
|
setAudioOutputDevice,
|
||||||
|
initAudioDeviceListener
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
persist: {
|
persist: {
|
||||||
key: 'player-core-store',
|
key: 'player-core-store',
|
||||||
storage: localStorage,
|
storage: localStorage,
|
||||||
pick: ['playMusic', 'playMusicUrl', 'playbackRate', 'volume', 'isPlay']
|
pick: ['playMusic', 'playMusicUrl', 'playbackRate', 'volume', 'isPlay', 'audioOutputDeviceId']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -192,11 +192,21 @@ export const usePlaylistStore = defineStore(
|
|||||||
keepIndex: boolean = false,
|
keepIndex: boolean = false,
|
||||||
fromIntelligenceMode: boolean = false
|
fromIntelligenceMode: boolean = false
|
||||||
) => {
|
) => {
|
||||||
// 如果不是从心动模式调用,清除心动模式状态
|
// 如果不是从心动模式调用,清除心动模式状态并切换播放模式
|
||||||
if (!fromIntelligenceMode) {
|
if (!fromIntelligenceMode) {
|
||||||
const intelligenceStore = useIntelligenceModeStore();
|
const intelligenceStore = useIntelligenceModeStore();
|
||||||
|
console.log('[PlaylistStore.setPlayList] 检查心动模式状态:', {
|
||||||
|
isIntelligenceMode: intelligenceStore.isIntelligenceMode,
|
||||||
|
currentPlayMode: playMode.value,
|
||||||
|
fromIntelligenceMode
|
||||||
|
});
|
||||||
|
|
||||||
if (intelligenceStore.isIntelligenceMode) {
|
if (intelligenceStore.isIntelligenceMode) {
|
||||||
intelligenceStore.clearIntelligenceMode();
|
console.log('[PlaylistStore] 退出心动模式,切换播放模式为顺序播放');
|
||||||
|
playMode.value = 0;
|
||||||
|
// 清除心动模式状态
|
||||||
|
intelligenceStore.clearIntelligenceMode(true);
|
||||||
|
console.log('[PlaylistStore] 心动模式已退出,新的播放模式:', playMode.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,7 +365,7 @@ export const usePlaylistStore = defineStore(
|
|||||||
if (!isIntelligence && wasIntelligence) {
|
if (!isIntelligence && wasIntelligence) {
|
||||||
console.log('退出心动模式');
|
console.log('退出心动模式');
|
||||||
const intelligenceStore = useIntelligenceModeStore();
|
const intelligenceStore = useIntelligenceModeStore();
|
||||||
intelligenceStore.clearIntelligenceMode();
|
intelligenceStore.clearIntelligenceMode(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { createDiscreteApi } from 'naive-ui';
|
||||||
|
import { defineStore } from 'pinia';
|
||||||
|
import { computed, ref, shallowRef } from 'vue';
|
||||||
|
|
||||||
|
import * as podcastApi from '@/api/podcast';
|
||||||
|
import type { DjCategory, DjProgram, DjRadio } from '@/types/podcast';
|
||||||
|
|
||||||
|
const { message } = createDiscreteApi(['message']);
|
||||||
|
|
||||||
|
export const usePodcastStore = defineStore(
|
||||||
|
'podcast',
|
||||||
|
() => {
|
||||||
|
const subscribedRadios = shallowRef<DjRadio[]>([]);
|
||||||
|
const categories = shallowRef<DjCategory[]>([]);
|
||||||
|
const currentRadio = shallowRef<DjRadio | null>(null);
|
||||||
|
const currentPrograms = shallowRef<DjProgram[]>([]);
|
||||||
|
const recommendRadios = shallowRef<DjRadio[]>([]);
|
||||||
|
const todayPerfered = shallowRef<DjProgram[]>([]);
|
||||||
|
const recentPrograms = shallowRef<DjProgram[]>([]);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
|
||||||
|
const subscribedCount = computed(() => subscribedRadios.value.length);
|
||||||
|
|
||||||
|
const isRadioSubscribed = computed(() => {
|
||||||
|
return (rid: number) => subscribedRadios.value.some((r) => r.id === rid);
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchSubscribedRadios = async () => {
|
||||||
|
try {
|
||||||
|
isLoading.value = true;
|
||||||
|
const res = await podcastApi.getDjSublist();
|
||||||
|
subscribedRadios.value = res.data?.djRadios || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取订阅列表失败:', error);
|
||||||
|
message.error('获取订阅列表失败');
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSubscribe = async (radio: DjRadio) => {
|
||||||
|
const isSubed = isRadioSubscribed.value(radio.id);
|
||||||
|
try {
|
||||||
|
await podcastApi.subscribeDj(radio.id, isSubed ? 0 : 1);
|
||||||
|
|
||||||
|
if (isSubed) {
|
||||||
|
message.success('已取消订阅');
|
||||||
|
} else {
|
||||||
|
message.success('订阅成功');
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetchSubscribedRadios();
|
||||||
|
|
||||||
|
if (currentRadio.value?.id === radio.id) {
|
||||||
|
currentRadio.value = { ...currentRadio.value, subed: !isSubed };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('订阅操作失败:', error);
|
||||||
|
message.error(isSubed ? '取消订阅失败' : '订阅失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchRadioDetail = async (rid: number) => {
|
||||||
|
try {
|
||||||
|
isLoading.value = true;
|
||||||
|
const res = await podcastApi.getDjDetail(rid);
|
||||||
|
currentRadio.value = res.data?.data;
|
||||||
|
if (currentRadio.value) {
|
||||||
|
currentRadio.value.subed = isRadioSubscribed.value(rid);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取电台详情失败:', error);
|
||||||
|
message.error('获取电台详情失败');
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchRadioPrograms = async (rid: number, offset = 0) => {
|
||||||
|
try {
|
||||||
|
isLoading.value = true;
|
||||||
|
const res = await podcastApi.getDjProgram(rid, 30, offset);
|
||||||
|
if (offset === 0) {
|
||||||
|
currentPrograms.value = res.data?.programs || [];
|
||||||
|
} else {
|
||||||
|
currentPrograms.value.push(...(res.data?.programs || []));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取节目列表失败:', error);
|
||||||
|
message.error('获取节目列表失败');
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchCategories = async () => {
|
||||||
|
try {
|
||||||
|
const res = await podcastApi.getDjCategoryList();
|
||||||
|
categories.value = res.data?.categories || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取分类列表失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchRecommendRadios = async () => {
|
||||||
|
try {
|
||||||
|
const res = await podcastApi.getDjRecommend();
|
||||||
|
recommendRadios.value = res.data?.djRadios || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取推荐电台失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchTodayPerfered = async () => {
|
||||||
|
try {
|
||||||
|
const res = await podcastApi.getDjTodayPerfered();
|
||||||
|
todayPerfered.value = res.data?.data || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取今日优选失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchRecentPrograms = async () => {
|
||||||
|
try {
|
||||||
|
const res = await podcastApi.getRecentDj();
|
||||||
|
recentPrograms.value = res.data?.data?.list || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取最近播放失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearCurrentRadio = () => {
|
||||||
|
currentRadio.value = null;
|
||||||
|
currentPrograms.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribedRadios,
|
||||||
|
categories,
|
||||||
|
currentRadio,
|
||||||
|
currentPrograms,
|
||||||
|
recommendRadios,
|
||||||
|
todayPerfered,
|
||||||
|
recentPrograms,
|
||||||
|
isLoading,
|
||||||
|
subscribedCount,
|
||||||
|
isRadioSubscribed,
|
||||||
|
fetchSubscribedRadios,
|
||||||
|
toggleSubscribe,
|
||||||
|
fetchRadioDetail,
|
||||||
|
fetchRadioPrograms,
|
||||||
|
fetchCategories,
|
||||||
|
fetchRecommendRadios,
|
||||||
|
fetchTodayPerfered,
|
||||||
|
fetchRecentPrograms,
|
||||||
|
clearCurrentRadio
|
||||||
|
};
|
||||||
|
},
|
||||||
|
{
|
||||||
|
persist: {
|
||||||
|
key: 'podcast-store',
|
||||||
|
storage: localStorage,
|
||||||
|
pick: ['subscribedRadios', 'categories']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -5,8 +5,23 @@ import { getDayRecommend } from '@/api/home';
|
|||||||
import type { IDayRecommend } from '@/types/day_recommend';
|
import type { IDayRecommend } from '@/types/day_recommend';
|
||||||
import type { SongResult } from '@/types/music';
|
import type { SongResult } from '@/types/music';
|
||||||
|
|
||||||
|
// 获取当前日期字符串 YYYY-MM-DD
|
||||||
|
const getTodayDateString = (): string => {
|
||||||
|
const now = new Date();
|
||||||
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
export const useRecommendStore = defineStore('recommend', () => {
|
export const useRecommendStore = defineStore('recommend', () => {
|
||||||
const dailyRecommendSongs = ref<SongResult[]>([]);
|
const dailyRecommendSongs = ref<SongResult[]>([]);
|
||||||
|
const lastFetchDate = ref<string>('');
|
||||||
|
|
||||||
|
// 检查数据是否过期(跨天)
|
||||||
|
const isDataStale = (): boolean => {
|
||||||
|
if (!lastFetchDate.value || dailyRecommendSongs.value.length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return lastFetchDate.value !== getTodayDateString();
|
||||||
|
};
|
||||||
|
|
||||||
const fetchDailyRecommendSongs = async () => {
|
const fetchDailyRecommendSongs = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -15,6 +30,7 @@ export const useRecommendStore = defineStore('recommend', () => {
|
|||||||
|
|
||||||
if (recommendData && Array.isArray(recommendData.dailySongs)) {
|
if (recommendData && Array.isArray(recommendData.dailySongs)) {
|
||||||
dailyRecommendSongs.value = recommendData.dailySongs as any;
|
dailyRecommendSongs.value = recommendData.dailySongs as any;
|
||||||
|
lastFetchDate.value = getTodayDateString();
|
||||||
console.log(`[Recommend Store] 已加载 ${recommendData.dailySongs.length} 首每日推荐歌曲。`);
|
console.log(`[Recommend Store] 已加载 ${recommendData.dailySongs.length} 首每日推荐歌曲。`);
|
||||||
} else {
|
} else {
|
||||||
dailyRecommendSongs.value = [];
|
dailyRecommendSongs.value = [];
|
||||||
@@ -25,6 +41,15 @@ export const useRecommendStore = defineStore('recommend', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 如果数据过期则刷新
|
||||||
|
const refreshIfStale = async (): Promise<boolean> => {
|
||||||
|
if (isDataStale()) {
|
||||||
|
await fetchDailyRecommendSongs();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
const replaceSongInDailyRecommend = (oldSongId: number | string, newSong: SongResult) => {
|
const replaceSongInDailyRecommend = (oldSongId: number | string, newSong: SongResult) => {
|
||||||
const index = dailyRecommendSongs.value.findIndex((song) => song.id === oldSongId);
|
const index = dailyRecommendSongs.value.findIndex((song) => song.id === oldSongId);
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
@@ -37,7 +62,10 @@ export const useRecommendStore = defineStore('recommend', () => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
dailyRecommendSongs,
|
dailyRecommendSongs,
|
||||||
|
lastFetchDate,
|
||||||
|
isDataStale,
|
||||||
fetchDailyRecommendSongs,
|
fetchDailyRecommendSongs,
|
||||||
|
refreshIfStale,
|
||||||
replaceSongInDailyRecommend
|
replaceSongInDailyRecommend
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
import { ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
import { logout } from '@/api/login';
|
import { logout } from '@/api/login';
|
||||||
import { getLikedList } from '@/api/music';
|
import { getLikedList } from '@/api/music';
|
||||||
import { getUserAlbumSublist, getUserPlaylist } from '@/api/user';
|
import { getUserAlbumSublist, getUserPlaylist } from '@/api/user';
|
||||||
|
import type { IUserDetail } from '@/types/user';
|
||||||
import { clearLoginStatus } from '@/utils/auth';
|
import { clearLoginStatus } from '@/utils/auth';
|
||||||
|
|
||||||
interface UserData {
|
interface UserData {
|
||||||
@@ -23,6 +24,8 @@ function getLocalStorageItem<T>(key: string, defaultValue: T): T {
|
|||||||
export const useUserStore = defineStore('user', () => {
|
export const useUserStore = defineStore('user', () => {
|
||||||
// 状态
|
// 状态
|
||||||
const user = ref<UserData | null>(getLocalStorageItem('user', null));
|
const user = ref<UserData | null>(getLocalStorageItem('user', null));
|
||||||
|
const userDetail = ref<IUserDetail | null>(null);
|
||||||
|
const recordList = ref<any[]>([]);
|
||||||
const loginType = ref<'token' | 'cookie' | 'qr' | 'uid' | null>(
|
const loginType = ref<'token' | 'cookie' | 'qr' | 'uid' | null>(
|
||||||
getLocalStorageItem('loginType', null)
|
getLocalStorageItem('loginType', null)
|
||||||
);
|
);
|
||||||
@@ -205,6 +208,8 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
initializeCollectedAlbums,
|
initializeCollectedAlbums,
|
||||||
addCollectedAlbum,
|
addCollectedAlbum,
|
||||||
removeCollectedAlbum,
|
removeCollectedAlbum,
|
||||||
isAlbumCollected
|
isAlbumCollected,
|
||||||
|
userDetail,
|
||||||
|
recordList
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export interface IArtist {
|
|||||||
albumSize: number;
|
albumSize: number;
|
||||||
musicSize: number;
|
musicSize: number;
|
||||||
mvSize: number;
|
mvSize: number;
|
||||||
|
picUrl?: string; // Optional fallback for cover image
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Rank {
|
interface Rank {
|
||||||
|
|||||||
Vendored
+12
@@ -0,0 +1,12 @@
|
|||||||
|
// 扩展 AudioContext 以支持 setSinkId (Chromium 110+)
|
||||||
|
interface AudioContext {
|
||||||
|
setSinkId(sinkId: string): Promise<void>;
|
||||||
|
readonly sinkId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 音频输出设备类型
|
||||||
|
export type AudioOutputDevice = {
|
||||||
|
deviceId: string;
|
||||||
|
label: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
};
|
||||||
@@ -1,24 +1,8 @@
|
|||||||
// 音乐平台类型
|
// 音乐平台类型
|
||||||
export type Platform =
|
export type Platform = 'qq' | 'migu' | 'kugou' | 'kuwo' | 'pyncmd' | 'joox' | 'gdmusic' | 'lxMusic';
|
||||||
| 'qq'
|
|
||||||
| 'migu'
|
|
||||||
| 'kugou'
|
|
||||||
| 'kuwo'
|
|
||||||
| 'pyncmd'
|
|
||||||
| 'joox'
|
|
||||||
| 'bilibili'
|
|
||||||
| 'gdmusic'
|
|
||||||
| 'lxMusic';
|
|
||||||
|
|
||||||
// 默认平台列表
|
// 默认平台列表
|
||||||
export const DEFAULT_PLATFORMS: Platform[] = [
|
export const DEFAULT_PLATFORMS: Platform[] = ['lxMusic', 'migu', 'kugou', 'kuwo', 'pyncmd'];
|
||||||
'lxMusic',
|
|
||||||
'migu',
|
|
||||||
'kugou',
|
|
||||||
'kuwo',
|
|
||||||
'pyncmd',
|
|
||||||
'bilibili'
|
|
||||||
];
|
|
||||||
|
|
||||||
export interface IRecommendMusic {
|
export interface IRecommendMusic {
|
||||||
code: number;
|
code: number;
|
||||||
@@ -70,11 +54,7 @@ export interface SongResult {
|
|||||||
lyric?: ILyric;
|
lyric?: ILyric;
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
primaryColor?: string;
|
primaryColor?: string;
|
||||||
bilibiliData?: {
|
source?: 'netease';
|
||||||
bvid: string;
|
|
||||||
cid: number;
|
|
||||||
};
|
|
||||||
source?: 'netease' | 'bilibili';
|
|
||||||
// 过期时间
|
// 过期时间
|
||||||
expiredAt?: number;
|
expiredAt?: number;
|
||||||
// 获取时间
|
// 获取时间
|
||||||
@@ -83,6 +63,7 @@ export interface SongResult {
|
|||||||
duration?: number;
|
duration?: number;
|
||||||
dt?: number;
|
dt?: number;
|
||||||
isFirstPlay?: boolean;
|
isFirstPlay?: boolean;
|
||||||
|
isPodcast?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Song {
|
export interface Song {
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/**
|
||||||
|
* 播客/电台相关类型定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 电台分类
|
||||||
|
export type DjCategory = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
pic56x56Url?: string;
|
||||||
|
pic84x84Url?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 电台主播信息
|
||||||
|
export type DjUser = {
|
||||||
|
userId: number;
|
||||||
|
nickname: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 电台信息
|
||||||
|
export type DjRadio = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
picUrl: string;
|
||||||
|
desc: string;
|
||||||
|
subCount: number;
|
||||||
|
programCount: number;
|
||||||
|
createTime: number;
|
||||||
|
categoryId: number;
|
||||||
|
category: string;
|
||||||
|
radioFeeType: number;
|
||||||
|
feeScope: number;
|
||||||
|
dj: DjUser;
|
||||||
|
subed?: boolean;
|
||||||
|
rcmdText?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 电台节目歌曲信息
|
||||||
|
export type DjMainSong = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
duration: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 电台节目电台信息
|
||||||
|
export type DjProgramRadio = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 电台节目
|
||||||
|
export type DjProgram = {
|
||||||
|
id: number;
|
||||||
|
mainSong: DjMainSong;
|
||||||
|
radio: DjProgramRadio;
|
||||||
|
coverUrl: string;
|
||||||
|
description: string;
|
||||||
|
createTime: number;
|
||||||
|
listenerCount: number;
|
||||||
|
commentCount: number;
|
||||||
|
liked: boolean;
|
||||||
|
likedCount: number;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// API 响应类型
|
||||||
|
export type DjSublistResponse = {
|
||||||
|
djRadios: DjRadio[];
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DjProgramResponse = {
|
||||||
|
programs: DjProgram[];
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DjDetailResponse = {
|
||||||
|
data: DjRadio;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DjRecommendResponse = {
|
||||||
|
djRadios: DjRadio[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DjCategoryListResponse = {
|
||||||
|
categories: DjCategory[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DjTodayPerferedResponse = {
|
||||||
|
data: DjProgram[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DjToplistResponse = {
|
||||||
|
toplist: DjRadio[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DjRadioHotResponse = {
|
||||||
|
djRadios: DjRadio[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DjProgramDetailResponse = {
|
||||||
|
program: DjProgram;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecentDjResponse = {
|
||||||
|
data: {
|
||||||
|
list: DjProgram[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PersonalizedDjProgramResponse = {
|
||||||
|
result: DjProgram[];
|
||||||
|
};
|
||||||
@@ -32,6 +32,19 @@ export const setAnimationDelay = (index: number = 6, time: number = 50) => {
|
|||||||
return `animation-delay:${(index * time) / (speed * 2)}ms`;
|
return `animation-delay:${(index * time) / (speed * 2)}ms`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 计算动画延迟(秒) - 用于新的动画效果
|
||||||
|
// 根据动画速度配置自动调整延迟时间
|
||||||
|
export const calculateAnimationDelay = (index: any, baseDelay: number = 0.03): string => {
|
||||||
|
const settingsStore = useSettingsStore();
|
||||||
|
if (settingsStore.setData?.noAnimate) {
|
||||||
|
return '0s';
|
||||||
|
}
|
||||||
|
const speed = settingsStore.setData?.animationSpeed || 1;
|
||||||
|
// 速度越快,延迟应该越短,所以除以 speed
|
||||||
|
const delay = (index * baseDelay) / speed;
|
||||||
|
return `${delay.toFixed(3)}s`;
|
||||||
|
};
|
||||||
|
|
||||||
// 将秒转换为分钟和秒
|
// 将秒转换为分钟和秒
|
||||||
export const secondToMinute = (s: number) => {
|
export const secondToMinute = (s: number) => {
|
||||||
if (!s) {
|
if (!s) {
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import type { SongResult } from '@/types/music';
|
||||||
|
import type { DjProgram } from '@/types/podcast';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将播客节目转换为播放列表所需的 SongResult 格式
|
||||||
|
* @param program 播客节目数据
|
||||||
|
* @returns SongResult 格式数据
|
||||||
|
*/
|
||||||
|
export const mapDjProgramToSongResult = (program: DjProgram): SongResult => {
|
||||||
|
return {
|
||||||
|
id: program.mainSong.id,
|
||||||
|
name: program.mainSong.name || program.name || '播客节目',
|
||||||
|
duration: program.mainSong.duration,
|
||||||
|
picUrl: program.coverUrl,
|
||||||
|
ar: [
|
||||||
|
{
|
||||||
|
id: program.radio.id,
|
||||||
|
name: program.radio.name,
|
||||||
|
picId: 0,
|
||||||
|
img1v1Id: 0,
|
||||||
|
briefDesc: '',
|
||||||
|
picUrl: '',
|
||||||
|
img1v1Url: '',
|
||||||
|
albumSize: 0,
|
||||||
|
alias: [],
|
||||||
|
trans: '',
|
||||||
|
musicSize: 0,
|
||||||
|
topicPerson: 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
al: {
|
||||||
|
id: program.radio.id,
|
||||||
|
name: program.radio.name,
|
||||||
|
picUrl: program.coverUrl,
|
||||||
|
type: '',
|
||||||
|
size: 0,
|
||||||
|
picId: 0,
|
||||||
|
blurPicUrl: '',
|
||||||
|
companyId: 0,
|
||||||
|
pic: 0,
|
||||||
|
picId_str: '',
|
||||||
|
publishTime: 0,
|
||||||
|
description: '',
|
||||||
|
tags: '',
|
||||||
|
company: '',
|
||||||
|
briefDesc: '',
|
||||||
|
artist: {
|
||||||
|
id: 0,
|
||||||
|
name: '',
|
||||||
|
picUrl: '',
|
||||||
|
alias: [],
|
||||||
|
albumSize: 0,
|
||||||
|
picId: 0,
|
||||||
|
img1v1Url: '',
|
||||||
|
img1v1Id: 0,
|
||||||
|
trans: '',
|
||||||
|
briefDesc: '',
|
||||||
|
musicSize: 0,
|
||||||
|
topicPerson: 0
|
||||||
|
},
|
||||||
|
songs: [],
|
||||||
|
alias: [],
|
||||||
|
status: 0,
|
||||||
|
copyrightId: 0,
|
||||||
|
commentThreadId: '',
|
||||||
|
artists: [],
|
||||||
|
subType: '',
|
||||||
|
onSale: false,
|
||||||
|
mark: 0
|
||||||
|
},
|
||||||
|
source: 'netease',
|
||||||
|
count: 0,
|
||||||
|
isPodcast: true,
|
||||||
|
program
|
||||||
|
};
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user