feat: 扩展数据层与播放能力

This commit is contained in:
alger
2026-02-04 20:10:28 +08:00
parent a44addef22
commit 3a3820cf52
29 changed files with 1111 additions and 675 deletions

View 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 });
};

View File

@@ -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站视频详情APIbvid:', 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站视频播放地址APIbvid:', 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;
};

View File

@@ -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');
};

View File

@@ -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()
];

View 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 } });
};

View File

@@ -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;

View 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
};
};

View 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
};
};

View 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
};
};

View File

@@ -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;

View File

@@ -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',

View File

@@ -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;

View File

@@ -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;

View File

@@ -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();
}
}
});

View File

@@ -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})`
);

View File

@@ -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';

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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']
}
}
);

View File

@@ -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);
}
};

View 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']
}
}
);

View File

@@ -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
};
});

View File

@@ -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
};
});

View File

@@ -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
View 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;
};

View File

@@ -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 {

View 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[];
};

View File

@@ -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) {

View 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
};
};