diff --git a/src/renderer/api/album.ts b/src/renderer/api/album.ts new file mode 100644 index 0000000..5cba8f1 --- /dev/null +++ b/src/renderer/api/album.ts @@ -0,0 +1,5 @@ +import request from '@/utils/request'; + +export const getNewAlbums = (params: { limit: number; offset: number; area: string }) => { + return request.get('/album/new', { params }); +}; diff --git a/src/renderer/api/bilibili.ts b/src/renderer/api/bilibili.ts deleted file mode 100644 index c0aca42..0000000 --- a/src/renderer/api/bilibili.ts +++ /dev/null @@ -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 => { - 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 { - code: number; - message: string; - ttl: number; - data: T; -} - -/** - * 获取B站视频详情 - * @param bvid B站视频BV号 - * @returns 视频详情响应 - */ -export const getBilibiliVideoDetail = ( - bvid: string -): Promise> => { - 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> => { - 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 => { - 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 => { - 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 => { - 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; -}; diff --git a/src/renderer/api/home.ts b/src/renderer/api/home.ts index aa7e6ef..251dc98 100644 --- a/src/renderer/api/home.ts +++ b/src/renderer/api/home.ts @@ -50,3 +50,38 @@ export const getDayRecommend = () => { export const getNewAlbum = () => { return request.get('/album/newest'); }; + +// 获取轮播图 +export const getBanners = (type: number = 0) => { + return request.get('/banner', { params: { type } }); +}; + +// 获取推荐歌单 +export const getPersonalizedPlaylist = (limit: number = 30) => { + return request.get('/personalized', { params: { limit } }); +}; + +// 获取私人漫游 +export const getPersonalFM = () => { + return request.get('/personal_fm'); +}; + +// 获取独家放送 +export const getPrivateContent = () => { + return request.get('/personalized/privatecontent'); +}; + +// 获取推荐MV +export const getPersonalizedMV = () => { + return request.get('/personalized/mv'); +}; + +// 获取新碟上架 +export const getTopAlbum = (params?: { limit?: number; offset?: number; area?: string }) => { + return request.get('/top/album', { params }); +}; + +// 获取推荐电台 +export const getPersonalizedDJ = () => { + return request.get('/personalized/djprogram'); +}; diff --git a/src/renderer/api/musicParser.ts b/src/renderer/api/musicParser.ts index 2b6ac71..313e2ff 100644 --- a/src/renderer/api/musicParser.ts +++ b/src/renderer/api/musicParser.ts @@ -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 { - // 检查失败缓存 - 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() ]; diff --git a/src/renderer/api/podcast.ts b/src/renderer/api/podcast.ts new file mode 100644 index 0000000..cfb41a0 --- /dev/null +++ b/src/renderer/api/podcast.ts @@ -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('/dj/sublist'); +}; + +export const getDjDetail = (rid: number) => { + return request.get('/dj/detail', { params: { rid } }); +}; + +export const getDjProgram = (rid: number, limit = 30, offset = 0, asc = false) => { + return request.get('/dj/program', { + params: { rid, limit, offset, asc } + }); +}; + +export const getDjProgramDetail = (id: number) => { + return request.get('/dj/program/detail', { params: { id } }); +}; + +export const getDjRecommend = () => { + return request.get('/dj/recommend'); +}; + +export const getDjCategoryList = () => { + return request.get('/dj/catelist'); +}; + +export const getDjRecommendByType = (type: number) => { + return request.get('/dj/recommend/type', { params: { type } }); +}; + +export const getDjCategoryRecommend = () => { + return request.get('/dj/category/recommend'); +}; + +export const getDjTodayPerfered = () => { + return request.get('/dj/today/perfered'); +}; + +export const getDjPersonalizeRecommend = (limit = 5) => { + return request.get('/dj/personalize/recommend', { params: { limit } }); +}; + +export const getDjBanner = () => { + return request.get('/dj/banner'); +}; + +export const getPersonalizedDjProgram = () => { + return request.get('/personalized/djprogram'); +}; + +export const getDjToplist = (type: 'new' | 'hot', limit = 100) => { + return request.get('/dj/toplist', { params: { type, limit } }); +}; + +export const getDjRadioHot = (cateId: number, limit = 30, offset = 0) => { + return request.get('/dj/radio/hot', { + params: { cateId, limit, offset } + }); +}; + +export const getRecentDj = () => { + return request.get('/record/recent/dj'); +}; + +export const getDjComment = (id: number, limit = 20, offset = 0) => { + return request.get('/comment/dj', { params: { id, limit, offset } }); +}; diff --git a/src/renderer/const/bar-const.ts b/src/renderer/const/bar-const.ts index 29f7358..aa0a1b9 100644 --- a/src/renderer/const/bar-const.ts +++ b/src/renderer/const/bar-const.ts @@ -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; diff --git a/src/renderer/hooks/PodcastHistoryHook.ts b/src/renderer/hooks/PodcastHistoryHook.ts new file mode 100644 index 0000000..eefc4a5 --- /dev/null +++ b/src/renderer/hooks/PodcastHistoryHook.ts @@ -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('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 + }; +}; diff --git a/src/renderer/hooks/PodcastRadioHistoryHook.ts b/src/renderer/hooks/PodcastRadioHistoryHook.ts new file mode 100644 index 0000000..52ae930 --- /dev/null +++ b/src/renderer/hooks/PodcastRadioHistoryHook.ts @@ -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('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 + }; +}; diff --git a/src/renderer/hooks/useDownloadStatus.ts b/src/renderer/hooks/useDownloadStatus.ts new file mode 100644 index 0000000..e53f13e --- /dev/null +++ b/src/renderer/hooks/useDownloadStatus.ts @@ -0,0 +1,94 @@ +import { computed, onMounted, ref } from 'vue'; +import { useRouter } from 'vue-router'; + +const downloadList = ref([]); +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 + }; +}; diff --git a/src/renderer/hooks/usePlayerHooks.ts b/src/renderer/hooks/usePlayerHooks.ts index 89a8035..d5e94bc 100644 --- a/src/renderer/hooks/usePlayerHooks.ts +++ b/src/renderer/hooks/usePlayerHooks.ts @@ -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 => { - 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; diff --git a/src/renderer/router/home.ts b/src/renderer/router/home.ts index 10389ea..73b9941 100644 --- a/src/renderer/router/home.ts +++ b/src/renderer/router/home.ts @@ -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', diff --git a/src/renderer/router/other.ts b/src/renderer/router/other.ts index d067cf1..579bf30 100644 --- a/src/renderer/router/other.ts +++ b/src/renderer/router/other.ts @@ -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; diff --git a/src/renderer/services/audioService.ts b/src/renderer/services/audioService.ts index 0c230d6..90207b3 100644 --- a/src/renderer/services/audioService.ts +++ b/src/renderer/services/audioService.ts @@ -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 { + 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 { + 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 { + 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; diff --git a/src/renderer/services/eqService.ts b/src/renderer/services/eqService.ts index c690cad..df8ebc7 100644 --- a/src/renderer/services/eqService.ts +++ b/src/renderer/services/eqService.ts @@ -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(); + } } }); diff --git a/src/renderer/services/preloadService.ts b/src/renderer/services/preloadService.ts index 6985ac4..c532895 100644 --- a/src/renderer/services/preloadService.ts +++ b/src/renderer/services/preloadService.ts @@ -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})` ); diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index e81588b..df79762 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -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'; diff --git a/src/renderer/store/modules/intelligenceMode.ts b/src/renderer/store/modules/intelligenceMode.ts index 22d54a5..2ba0dda 100644 --- a/src/renderer/store/modules/intelligenceMode.ts +++ b/src/renderer/store/modules/intelligenceMode.ts @@ -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 { diff --git a/src/renderer/store/modules/music.ts b/src/renderer/store/modules/music.ts index e03c455..b57d9a2 100644 --- a/src/renderer/store/modules/music.ts +++ b/src/renderer/store/modules/music.ts @@ -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; diff --git a/src/renderer/store/modules/playerCore.ts b/src/renderer/store/modules/playerCore.ts index b2c88dc..6e3f30b 100644 --- a/src/renderer/store/modules/playerCore.ts +++ b/src/renderer/store/modules/playerCore.ts @@ -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( + localStorage.getItem('audioOutputDeviceId') || 'default' + ); + const availableAudioDevices = ref([]); + 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 => { + 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'] } } ); diff --git a/src/renderer/store/modules/playlist.ts b/src/renderer/store/modules/playlist.ts index 12e7313..7805d30 100644 --- a/src/renderer/store/modules/playlist.ts +++ b/src/renderer/store/modules/playlist.ts @@ -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); } }; diff --git a/src/renderer/store/modules/podcast.ts b/src/renderer/store/modules/podcast.ts new file mode 100644 index 0000000..c8fab4e --- /dev/null +++ b/src/renderer/store/modules/podcast.ts @@ -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([]); + const categories = shallowRef([]); + const currentRadio = shallowRef(null); + const currentPrograms = shallowRef([]); + const recommendRadios = shallowRef([]); + const todayPerfered = shallowRef([]); + const recentPrograms = shallowRef([]); + 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'] + } + } +); diff --git a/src/renderer/store/modules/recommend.ts b/src/renderer/store/modules/recommend.ts index 616ba65..d503a91 100644 --- a/src/renderer/store/modules/recommend.ts +++ b/src/renderer/store/modules/recommend.ts @@ -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([]); + const lastFetchDate = ref(''); + + // 检查数据是否过期(跨天) + 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 => { + 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 }; }); diff --git a/src/renderer/store/modules/user.ts b/src/renderer/store/modules/user.ts index 60ada29..618c5e5 100644 --- a/src/renderer/store/modules/user.ts +++ b/src/renderer/store/modules/user.ts @@ -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(key: string, defaultValue: T): T { export const useUserStore = defineStore('user', () => { // 状态 const user = ref(getLocalStorageItem('user', null)); + const userDetail = ref(null); + const recordList = ref([]); 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 }; }); diff --git a/src/renderer/types/artist.ts b/src/renderer/types/artist.ts index 77d4800..e841ff2 100644 --- a/src/renderer/types/artist.ts +++ b/src/renderer/types/artist.ts @@ -76,6 +76,7 @@ export interface IArtist { albumSize: number; musicSize: number; mvSize: number; + picUrl?: string; // Optional fallback for cover image } interface Rank { diff --git a/src/renderer/types/audio.d.ts b/src/renderer/types/audio.d.ts new file mode 100644 index 0000000..35d179e --- /dev/null +++ b/src/renderer/types/audio.d.ts @@ -0,0 +1,12 @@ +// 扩展 AudioContext 以支持 setSinkId (Chromium 110+) +interface AudioContext { + setSinkId(sinkId: string): Promise; + readonly sinkId: string; +} + +// 音频输出设备类型 +export type AudioOutputDevice = { + deviceId: string; + label: string; + isDefault: boolean; +}; diff --git a/src/renderer/types/music.ts b/src/renderer/types/music.ts index b2f62da..ed83561 100644 --- a/src/renderer/types/music.ts +++ b/src/renderer/types/music.ts @@ -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 { diff --git a/src/renderer/types/podcast.ts b/src/renderer/types/podcast.ts new file mode 100644 index 0000000..9ae290c --- /dev/null +++ b/src/renderer/types/podcast.ts @@ -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[]; +}; diff --git a/src/renderer/utils/index.ts b/src/renderer/utils/index.ts index 69c0d78..ea7746a 100644 --- a/src/renderer/utils/index.ts +++ b/src/renderer/utils/index.ts @@ -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) { diff --git a/src/renderer/utils/podcastUtils.ts b/src/renderer/utils/podcastUtils.ts new file mode 100644 index 0000000..0a25d06 --- /dev/null +++ b/src/renderer/utils/podcastUtils.ts @@ -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 + }; +};