mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-14 06:30:49 +08:00
feat: 扩展数据层与播放能力
This commit is contained in:
5
src/renderer/api/album.ts
Normal file
5
src/renderer/api/album.ts
Normal file
@@ -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 = () => {
|
||||
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 requestMusic from '@/utils/request_music';
|
||||
|
||||
import { searchAndGetBilibiliAudioUrl } from './bilibili';
|
||||
import type { ParsedMusicResult } from './gdmusic';
|
||||
import { parseFromGDMusic } from './gdmusic';
|
||||
import { LxMusicStrategy } from './lxMusicStrategy';
|
||||
@@ -164,7 +163,7 @@ export class CacheManager {
|
||||
console.log(`清除歌曲 ${id} 的URL缓存`);
|
||||
|
||||
// 清除失败缓存 - 需要遍历所有策略
|
||||
const strategies = ['custom', 'bilibili', 'gdmusic', 'unblockMusic'];
|
||||
const strategies = ['custom', 'gdmusic', 'unblockMusic'];
|
||||
for (const strategy of strategies) {
|
||||
const cacheKey = `${id}_${strategy}`;
|
||||
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
|
||||
* @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音乐台解析策略
|
||||
*/
|
||||
@@ -451,9 +386,7 @@ class UnblockMusicStrategy implements MusicSourceStrategy {
|
||||
priority = 4;
|
||||
|
||||
canHandle(sources: string[]): boolean {
|
||||
const unblockSources = sources.filter(
|
||||
(source) => !['custom', 'bilibili', 'gdmusic'].includes(source)
|
||||
);
|
||||
const unblockSources = sources.filter((source) => !['custom', 'gdmusic'].includes(source));
|
||||
return unblockSources.length > 0;
|
||||
}
|
||||
|
||||
@@ -470,7 +403,7 @@ class UnblockMusicStrategy implements MusicSourceStrategy {
|
||||
|
||||
try {
|
||||
const unblockSources = (sources || []).filter(
|
||||
(source) => !['custom', 'bilibili', 'gdmusic'].includes(source)
|
||||
(source) => !['custom', 'gdmusic'].includes(source)
|
||||
);
|
||||
console.log('尝试使用UnblockMusic解析:', unblockSources);
|
||||
|
||||
@@ -502,7 +435,6 @@ class MusicSourceStrategyFactory {
|
||||
private static strategies: MusicSourceStrategy[] = [
|
||||
new LxMusicStrategy(),
|
||||
new CustomApiStrategy(),
|
||||
new BilibiliStrategy(),
|
||||
new GDMusicStrategy(),
|
||||
new UnblockMusicStrategy()
|
||||
];
|
||||
|
||||
86
src/renderer/api/podcast.ts
Normal file
86
src/renderer/api/podcast.ts
Normal file
@@ -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
|
||||
},
|
||||
{
|
||||
label: 'search.search.bilibili', // B站
|
||||
key: 2000
|
||||
label: 'search.search.djradio', // 电台
|
||||
key: 1009
|
||||
}
|
||||
];
|
||||
|
||||
@@ -50,5 +50,5 @@ export const SEARCH_TYPE = {
|
||||
ARTIST: 100, // 歌手
|
||||
PLAYLIST: 1000, // 歌单
|
||||
MV: 1004, // MV
|
||||
BILIBILI: 2000 // B站视频
|
||||
DJ_RADIO: 1009 // 电台
|
||||
} as const;
|
||||
|
||||
50
src/renderer/hooks/PodcastHistoryHook.ts
Normal file
50
src/renderer/hooks/PodcastHistoryHook.ts
Normal file
@@ -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
|
||||
};
|
||||
};
|
||||
66
src/renderer/hooks/PodcastRadioHistoryHook.ts
Normal file
66
src/renderer/hooks/PodcastRadioHistoryHook.ts
Normal file
@@ -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
|
||||
};
|
||||
};
|
||||
94
src/renderer/hooks/useDownloadStatus.ts
Normal file
94
src/renderer/hooks/useDownloadStatus.ts
Normal file
@@ -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 i18n from '@/../i18n/renderer';
|
||||
import { getBilibiliAudioUrl } from '@/api/bilibili';
|
||||
import { getMusicLrc, getMusicUrl, getParsingMusicUrl } from '@/api/music';
|
||||
import { playbackRequestManager } from '@/services/playbackRequestManager';
|
||||
import { SongSourceConfigManager } from '@/services/SongSourceConfigManager';
|
||||
@@ -39,28 +38,6 @@ export const getSongUrl = async (
|
||||
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最优先 ====================
|
||||
const globalSources = settingsStore.setData.enabledMusicSources || [];
|
||||
const useCustomApiGlobally = globalSources.includes('custom');
|
||||
@@ -108,7 +85,7 @@ export const getSongUrl = async (
|
||||
}
|
||||
|
||||
// 如果有自定义音源设置,直接使用getParsingMusicUrl获取URL
|
||||
if (songConfig && songData.source !== 'bilibili') {
|
||||
if (songConfig) {
|
||||
try {
|
||||
console.log(`使用自定义音源解析歌曲 ID: ${id}`);
|
||||
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> => {
|
||||
if (typeof id === 'string' && id.includes('--')) {
|
||||
console.log('B站音频,无需加载歌词');
|
||||
return {
|
||||
lrcTimeArray: [],
|
||||
lrcArray: [],
|
||||
hasWordByWord: false
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const numericId = typeof id === 'string' ? parseInt(id, 10) : id;
|
||||
const { data } = await getMusicLrc(numericId);
|
||||
@@ -346,30 +314,6 @@ export const useSongDetail = () => {
|
||||
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()) {
|
||||
console.info(`歌曲已过期,重新获取: ${playMusic.name}`);
|
||||
playMusic.playMusicUrl = undefined;
|
||||
|
||||
@@ -32,6 +32,17 @@ const layoutRouter = [
|
||||
},
|
||||
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',
|
||||
name: 'toplist',
|
||||
@@ -77,6 +88,17 @@ const layoutRouter = [
|
||||
},
|
||||
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',
|
||||
name: 'set',
|
||||
|
||||
@@ -55,17 +55,6 @@ const otherRouter = [
|
||||
},
|
||||
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?',
|
||||
name: 'musicList',
|
||||
@@ -130,6 +119,41 @@ const otherRouter = [
|
||||
back: true
|
||||
},
|
||||
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;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Howl, Howler } from 'howler';
|
||||
|
||||
import type { AudioOutputDevice } from '@/types/audio';
|
||||
import type { SongResult } from '@/types/music';
|
||||
import { isElectron } from '@/utils'; // 导入isElectron常量
|
||||
|
||||
@@ -21,6 +22,10 @@ class AudioService {
|
||||
|
||||
private playbackRate = 1.0; // 添加播放速度属性
|
||||
|
||||
private currentSinkId: string = 'default';
|
||||
|
||||
private contextStateMonitoringInitialized = false;
|
||||
|
||||
// 预设的 EQ 频段
|
||||
private readonly frequencies = [31, 62, 125, 250, 500, 1000, 2000, 4000, 8000, 16000];
|
||||
|
||||
@@ -304,6 +309,12 @@ class AudioService {
|
||||
await this.context.resume();
|
||||
}
|
||||
|
||||
// 设置 AudioContext 状态监控
|
||||
this.setupContextStateMonitoring();
|
||||
|
||||
// 恢复保存的音频输出设备
|
||||
this.restoreSavedAudioDevice();
|
||||
|
||||
// 清理现有连接
|
||||
await this.disposeEQ(true);
|
||||
|
||||
@@ -360,10 +371,24 @@ class AudioService {
|
||||
if (!this.source || !this.gainNode || !this.context) return;
|
||||
|
||||
try {
|
||||
// 断开所有现有连接
|
||||
this.source.disconnect();
|
||||
this.filters.forEach((filter) => filter.disconnect());
|
||||
this.gainNode.disconnect();
|
||||
// 断开所有现有连接(捕获已断开的错误)
|
||||
try {
|
||||
this.source.disconnect();
|
||||
} catch {
|
||||
/* already disconnected */
|
||||
}
|
||||
this.filters.forEach((filter) => {
|
||||
try {
|
||||
filter.disconnect();
|
||||
} catch {
|
||||
/* already disconnected */
|
||||
}
|
||||
});
|
||||
try {
|
||||
this.gainNode.disconnect();
|
||||
} catch {
|
||||
/* already disconnected */
|
||||
}
|
||||
|
||||
if (this.bypass) {
|
||||
// EQ被禁用时,直接连接到输出
|
||||
@@ -381,7 +406,17 @@ class AudioService {
|
||||
this.gainNode.connect(this.context.destination);
|
||||
}
|
||||
} 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;
|
||||
Howler.masterGain = this.context.createGain();
|
||||
Howler.masterGain.connect(this.context.destination);
|
||||
// 重新创建上下文后恢复输出设备
|
||||
this.restoreSavedAudioDevice();
|
||||
}
|
||||
|
||||
// 恢复上下文状态
|
||||
@@ -914,6 +951,137 @@ class AudioService {
|
||||
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) {
|
||||
if (!this.currentSound) return;
|
||||
this.playbackRate = rate;
|
||||
@@ -986,12 +1154,14 @@ class AudioService {
|
||||
// 1. Howler API是否报告正在播放
|
||||
// 2. 是否不在加载状态
|
||||
// 3. 确保音频上下文状态正常
|
||||
// 4. 确保音频图正确连接(在 Electron 环境中)
|
||||
const isPlaying = this.currentSound.playing();
|
||||
const isLoading = this.isLoading();
|
||||
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) {
|
||||
console.error('检查播放状态出错:', error);
|
||||
return false;
|
||||
|
||||
@@ -164,7 +164,9 @@ export class EQService {
|
||||
if (node) {
|
||||
node.disconnect();
|
||||
// 特殊清理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 手动选择正确的音源
|
||||
if (
|
||||
expectedDuration > 0 &&
|
||||
Math.abs(duration - expectedDuration) > 5 &&
|
||||
song.source !== 'bilibili'
|
||||
) {
|
||||
if (expectedDuration > 0 && Math.abs(duration - expectedDuration) > 5) {
|
||||
console.warn(
|
||||
`[PreloadService] 时长差异警告:实际 ${duration.toFixed(1)}s, 预期 ${expectedDuration.toFixed(1)}s (${song.name})`
|
||||
);
|
||||
|
||||
@@ -15,10 +15,14 @@ pinia.use(({ store }) => {
|
||||
});
|
||||
|
||||
// 导出所有 store
|
||||
export * from './modules/intelligenceMode';
|
||||
export * from './modules/lyric';
|
||||
export * from './modules/menu';
|
||||
export * from './modules/music';
|
||||
export * from './modules/player';
|
||||
export * from './modules/playerCore';
|
||||
export * from './modules/playlist';
|
||||
export * from './modules/podcast';
|
||||
export * from './modules/recommend';
|
||||
export * from './modules/search';
|
||||
export * from './modules/settings';
|
||||
|
||||
@@ -98,7 +98,6 @@ export const useIntelligenceModeStore = defineStore('intelligenceMode', () => {
|
||||
|
||||
setLocalStorageItem('isIntelligenceMode', true);
|
||||
setLocalStorageItem('intelligenceModeInfo', intelligenceModeInfo.value);
|
||||
setLocalStorageItem('playMode', playlistStore.playMode);
|
||||
|
||||
// 替换播放列表并开始播放
|
||||
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;
|
||||
intelligenceModeInfo.value = null;
|
||||
setLocalStorageItem('isIntelligenceMode', false);
|
||||
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 {
|
||||
|
||||
@@ -24,6 +24,14 @@ export const useMusicStore = defineStore('music', {
|
||||
this.canRemoveSong = canRemove;
|
||||
},
|
||||
|
||||
// 仅设置基础信息(用于先导航后获取数据)
|
||||
setBasicListInfo(name: string, listInfo: any = null, canRemove = false) {
|
||||
this.currentMusicList = null; // 标识数据未加载
|
||||
this.currentMusicListName = name;
|
||||
this.currentListInfo = listInfo;
|
||||
this.canRemoveSong = canRemove;
|
||||
},
|
||||
|
||||
// 清除当前音乐列表
|
||||
clearCurrentMusicList() {
|
||||
this.currentMusicList = null;
|
||||
|
||||
@@ -4,19 +4,21 @@ import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import i18n from '@/../i18n/renderer';
|
||||
import { getBilibiliAudioUrl } from '@/api/bilibili';
|
||||
import { getParsingMusicUrl } from '@/api/music';
|
||||
import { useMusicHistory } from '@/hooks/MusicHistoryHook';
|
||||
import { usePodcastHistory } from '@/hooks/PodcastHistoryHook';
|
||||
import { useLyrics, useSongDetail } from '@/hooks/usePlayerHooks';
|
||||
import { audioService } from '@/services/audioService';
|
||||
import { playbackRequestManager } from '@/services/playbackRequestManager';
|
||||
import { preloadService } from '@/services/preloadService';
|
||||
import { SongSourceConfigManager } from '@/services/SongSourceConfigManager';
|
||||
import type { AudioOutputDevice } from '@/types/audio';
|
||||
import type { Platform, SongResult } from '@/types/music';
|
||||
import { getImgUrl } from '@/utils';
|
||||
import { getImageLinearBackground } from '@/utils/linearColor';
|
||||
|
||||
const musicHistory = useMusicHistory();
|
||||
const podcastHistory = usePodcastHistory();
|
||||
const { message } = createDiscreteApi(['message']);
|
||||
|
||||
/**
|
||||
@@ -36,6 +38,12 @@ export const usePlayerCoreStore = defineStore(
|
||||
const volume = ref(1);
|
||||
const userPlayIntent = ref(false); // 用户是否想要播放
|
||||
|
||||
// 音频输出设备
|
||||
const audioOutputDeviceId = ref<string>(
|
||||
localStorage.getItem('audioOutputDeviceId') || 'default'
|
||||
);
|
||||
const availableAudioDevices = ref<AudioOutputDevice[]>([]);
|
||||
|
||||
let checkPlayTime: NodeJS.Timeout | null = null;
|
||||
|
||||
// ==================== Computed ====================
|
||||
@@ -239,14 +247,18 @@ export const usePlayerCoreStore = defineStore(
|
||||
(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;
|
||||
|
||||
try {
|
||||
// 添加到历史记录
|
||||
musicHistory.addMusic(music);
|
||||
if (music.isPodcast) {
|
||||
if (music.program) {
|
||||
podcastHistory.addPodcast(music.program);
|
||||
}
|
||||
} else {
|
||||
musicHistory.addMusic(music);
|
||||
}
|
||||
|
||||
// 获取歌曲详情
|
||||
const updatedPlayMusic = await getSongDetail(originalMusic, requestId);
|
||||
@@ -352,36 +364,6 @@ export const usePlayerCoreStore = defineStore(
|
||||
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 获取音频
|
||||
// 优先使用已预加载的 sound(通过 consume 获取并从缓存中移除)
|
||||
// 如果没有预加载,则进行加载
|
||||
@@ -514,11 +496,6 @@ export const usePlayerCoreStore = defineStore(
|
||||
return false;
|
||||
}
|
||||
|
||||
if (currentSong.source === 'bilibili') {
|
||||
console.warn('B站视频不支持重新解析');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 使用 SongSourceConfigManager 保存配置
|
||||
SongSourceConfigManager.setConfig(
|
||||
currentSong.id,
|
||||
@@ -579,11 +556,6 @@ export const usePlayerCoreStore = defineStore(
|
||||
console.log('恢复上次播放的音乐:', playMusic.value.name);
|
||||
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(
|
||||
{ ...playMusic.value, isFirstPlay: true, playMusicUrl: undefined },
|
||||
isPlaying
|
||||
@@ -602,6 +574,43 @@ export const usePlayerCoreStore = defineStore(
|
||||
}, 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 {
|
||||
// 状态
|
||||
play,
|
||||
@@ -612,6 +621,8 @@ export const usePlayerCoreStore = defineStore(
|
||||
playbackRate,
|
||||
volume,
|
||||
userPlayIntent,
|
||||
audioOutputDeviceId,
|
||||
availableAudioDevices,
|
||||
|
||||
// Computed
|
||||
currentSong,
|
||||
@@ -631,14 +642,17 @@ export const usePlayerCoreStore = defineStore(
|
||||
handlePause,
|
||||
checkPlaybackState,
|
||||
reparseCurrentSong,
|
||||
initializePlayState
|
||||
initializePlayState,
|
||||
refreshAudioDevices,
|
||||
setAudioOutputDevice,
|
||||
initAudioDeviceListener
|
||||
};
|
||||
},
|
||||
{
|
||||
persist: {
|
||||
key: 'player-core-store',
|
||||
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,
|
||||
fromIntelligenceMode: boolean = false
|
||||
) => {
|
||||
// 如果不是从心动模式调用,清除心动模式状态
|
||||
// 如果不是从心动模式调用,清除心动模式状态并切换播放模式
|
||||
if (!fromIntelligenceMode) {
|
||||
const intelligenceStore = useIntelligenceModeStore();
|
||||
console.log('[PlaylistStore.setPlayList] 检查心动模式状态:', {
|
||||
isIntelligenceMode: intelligenceStore.isIntelligenceMode,
|
||||
currentPlayMode: playMode.value,
|
||||
fromIntelligenceMode
|
||||
});
|
||||
|
||||
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) {
|
||||
console.log('退出心动模式');
|
||||
const intelligenceStore = useIntelligenceModeStore();
|
||||
intelligenceStore.clearIntelligenceMode();
|
||||
intelligenceStore.clearIntelligenceMode(true);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
166
src/renderer/store/modules/podcast.ts
Normal file
166
src/renderer/store/modules/podcast.ts
Normal file
@@ -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 { 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', () => {
|
||||
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 () => {
|
||||
try {
|
||||
@@ -15,6 +30,7 @@ export const useRecommendStore = defineStore('recommend', () => {
|
||||
|
||||
if (recommendData && Array.isArray(recommendData.dailySongs)) {
|
||||
dailyRecommendSongs.value = recommendData.dailySongs as any;
|
||||
lastFetchDate.value = getTodayDateString();
|
||||
console.log(`[Recommend Store] 已加载 ${recommendData.dailySongs.length} 首每日推荐歌曲。`);
|
||||
} else {
|
||||
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 index = dailyRecommendSongs.value.findIndex((song) => song.id === oldSongId);
|
||||
if (index !== -1) {
|
||||
@@ -37,7 +62,10 @@ export const useRecommendStore = defineStore('recommend', () => {
|
||||
|
||||
return {
|
||||
dailyRecommendSongs,
|
||||
lastFetchDate,
|
||||
isDataStale,
|
||||
fetchDailyRecommendSongs,
|
||||
refreshIfStale,
|
||||
replaceSongInDailyRecommend
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { logout } from '@/api/login';
|
||||
import { getLikedList } from '@/api/music';
|
||||
import { getUserAlbumSublist, getUserPlaylist } from '@/api/user';
|
||||
import type { IUserDetail } from '@/types/user';
|
||||
import { clearLoginStatus } from '@/utils/auth';
|
||||
|
||||
interface UserData {
|
||||
@@ -23,6 +24,8 @@ function getLocalStorageItem<T>(key: string, defaultValue: T): T {
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
// 状态
|
||||
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>(
|
||||
getLocalStorageItem('loginType', null)
|
||||
);
|
||||
@@ -205,6 +208,8 @@ export const useUserStore = defineStore('user', () => {
|
||||
initializeCollectedAlbums,
|
||||
addCollectedAlbum,
|
||||
removeCollectedAlbum,
|
||||
isAlbumCollected
|
||||
isAlbumCollected,
|
||||
userDetail,
|
||||
recordList
|
||||
};
|
||||
});
|
||||
|
||||
@@ -76,6 +76,7 @@ export interface IArtist {
|
||||
albumSize: number;
|
||||
musicSize: number;
|
||||
mvSize: number;
|
||||
picUrl?: string; // Optional fallback for cover image
|
||||
}
|
||||
|
||||
interface Rank {
|
||||
|
||||
12
src/renderer/types/audio.d.ts
vendored
Normal file
12
src/renderer/types/audio.d.ts
vendored
Normal file
@@ -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 =
|
||||
| 'qq'
|
||||
| 'migu'
|
||||
| 'kugou'
|
||||
| 'kuwo'
|
||||
| 'pyncmd'
|
||||
| 'joox'
|
||||
| 'bilibili'
|
||||
| 'gdmusic'
|
||||
| 'lxMusic';
|
||||
export type Platform = 'qq' | 'migu' | 'kugou' | 'kuwo' | 'pyncmd' | 'joox' | 'gdmusic' | 'lxMusic';
|
||||
|
||||
// 默认平台列表
|
||||
export const DEFAULT_PLATFORMS: Platform[] = [
|
||||
'lxMusic',
|
||||
'migu',
|
||||
'kugou',
|
||||
'kuwo',
|
||||
'pyncmd',
|
||||
'bilibili'
|
||||
];
|
||||
export const DEFAULT_PLATFORMS: Platform[] = ['lxMusic', 'migu', 'kugou', 'kuwo', 'pyncmd'];
|
||||
|
||||
export interface IRecommendMusic {
|
||||
code: number;
|
||||
@@ -70,11 +54,7 @@ export interface SongResult {
|
||||
lyric?: ILyric;
|
||||
backgroundColor?: string;
|
||||
primaryColor?: string;
|
||||
bilibiliData?: {
|
||||
bvid: string;
|
||||
cid: number;
|
||||
};
|
||||
source?: 'netease' | 'bilibili';
|
||||
source?: 'netease';
|
||||
// 过期时间
|
||||
expiredAt?: number;
|
||||
// 获取时间
|
||||
@@ -83,6 +63,7 @@ export interface SongResult {
|
||||
duration?: number;
|
||||
dt?: number;
|
||||
isFirstPlay?: boolean;
|
||||
isPodcast?: boolean;
|
||||
}
|
||||
|
||||
export interface Song {
|
||||
|
||||
113
src/renderer/types/podcast.ts
Normal file
113
src/renderer/types/podcast.ts
Normal file
@@ -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`;
|
||||
};
|
||||
|
||||
// 计算动画延迟(秒) - 用于新的动画效果
|
||||
// 根据动画速度配置自动调整延迟时间
|
||||
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) => {
|
||||
if (!s) {
|
||||
|
||||
76
src/renderer/utils/podcastUtils.ts
Normal file
76
src/renderer/utils/podcastUtils.ts
Normal file
@@ -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