feat: 添加B站音频URL获取功能,优化播放器逻辑,删除不再使用的BilibiliPlayer和MusicBar组件

This commit is contained in:
alger
2025-03-30 01:20:28 +08:00
parent 477f8bb99b
commit 1a440fad09
17 changed files with 464 additions and 611 deletions

View File

@@ -106,7 +106,6 @@ if (!isSingleInstance) {
// 监听语言切换
ipcMain.on('change-language', (_, locale: Language) => {
console.log('locale',locale)
// 更新主进程的语言设置
i18n.global.locale = locale;
// 更新托盘菜单

View File

@@ -1,5 +1,5 @@
import { is } from '@electron-toolkit/utils';
import { app, BrowserWindow, ipcMain, session, shell } from 'electron';
import { app, BrowserWindow, globalShortcut, ipcMain, session, shell } from 'electron';
import Store from 'electron-store';
import { join } from 'path';
@@ -112,6 +112,11 @@ export function createMainWindow(icon: Electron.NativeImage): BrowserWindow {
if (is.dev && process.env.ELECTRON_RENDERER_URL) {
mainWindow.webContents.openDevTools({ mode: 'detach' });
mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL);
// 注册快捷键 打开开发者工具
globalShortcut.register('CommandOrControl+Shift+I', () => {
mainWindow.webContents.openDevTools({ mode: 'detach' });
});
} else {
mainWindow.loadFile(join(__dirname, '../renderer/index.html'));
}

View File

@@ -126,3 +126,25 @@ export const getBilibiliProxyUrl = (url: string) => {
const AUrl = url.startsWith('http') ? url : `https:${url}`;
return `${import.meta.env.VITE_API}/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[0].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;
}
};

View File

@@ -1,410 +0,0 @@
<template>
<div
class="music-bar-container"
:class="{ playing: playerStore.isPlay && playerStore.playMusicUrl }"
>
<div class="music-bar-left">
<div
class="music-cover"
:class="{ loading: playerStore.currentSong?.playLoading }"
@click="openDetailPlayer"
>
<n-image
v-if="playerStore.currentSong && playerStore.currentSong.picUrl"
class="cover-image"
:src="getSourcePic(playerStore.currentSong)"
preview-disabled
:object-fit="'cover'"
/>
<n-spin v-if="playerStore.currentSong?.playLoading" size="small" />
</div>
<div class="music-info">
<div class="music-title ellipsis-text">
{{ playerStore.currentSong ? playerStore.currentSong.name : '' }}
</div>
<div class="music-artist ellipsis-text">
{{ getArtistName(playerStore.currentSong) }}
</div>
</div>
</div>
<div class="music-bar-center">
<div class="player-controls">
<n-button quaternary circle class="control-button prev-button" @click="handlePrevClick">
<template #icon>
<i class="ri-skip-back-fill"></i>
</template>
</n-button>
<n-button
quaternary
circle
class="control-button play-button"
:disabled="!playerStore.currentSong.id"
@click="handlePlayClick"
>
<template #icon>
<i :class="playerStore.isPlay ? 'ri-pause-fill' : 'ri-play-fill'"></i>
</template>
</n-button>
<n-button quaternary circle class="control-button next-button" @click="handleNextClick">
<template #icon>
<i class="ri-skip-forward-fill"></i>
</template>
</n-button>
</div>
<div class="progress-container">
<div class="time-text">{{ formatTime(currentTime) }}</div>
<n-slider
class="progress-slider"
:value="playerProgress"
:step="0.1"
:max="duration"
:tooltip="false"
@update:value="handleProgressUpdate"
/>
<div class="time-text">{{ formatTime(duration) }}</div>
</div>
</div>
<div class="music-bar-right">
<n-button quaternary circle class="control-button mode-button" @click="togglePlayMode">
<template #icon>
<i v-if="playerStore.playMode === 0" class="ri-repeat-2-line" />
<i v-else-if="playerStore.playMode === 1" class="ri-repeat-one-line" />
<i v-else-if="playerStore.playMode === 2" class="ri-shuffle-line" />
</template>
</n-button>
<n-button quaternary circle class="control-button list-button" @click="togglePlayList">
<template #icon>
<i class="ri-play-list-line"></i>
</template>
</n-button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { getBilibiliProxyUrl } from '@/api/bilibili';
import { usePlayerStore } from '@/store/modules/player';
import type { SongResult } from '@/type/music';
import { getImgUrl } from '@/utils';
const router = useRouter();
const playerStore = usePlayerStore();
const currentTime = ref(0);
const duration = ref(0);
const playerProgress = ref(0);
const showPlayList = ref(false);
let audioPlayer: HTMLAudioElement | null = null;
// 计算播放进度
const progress = computed(() => {
if (duration.value === 0) return 0;
return (currentTime.value / duration.value) * 100;
});
// 监听播放状态
watch(
() => playerStore.isPlay,
(isPlay) => {
if (!audioPlayer) return;
if (isPlay) {
audioPlayer.play().catch((error) => {
console.error('播放失败:', error);
playerStore.setIsPlay(false);
});
} else {
audioPlayer.pause();
}
}
);
// 监听播放URL变化
watch(
() => playerStore.playMusicUrl,
(newUrl) => {
if (!audioPlayer) return;
if (newUrl) {
audioPlayer.src = newUrl;
if (playerStore.play) {
audioPlayer.play().catch((error) => {
console.error('播放失败:', error);
playerStore.setIsPlay(false);
});
}
}
}
);
// 初始化播放器
onMounted(() => {
audioPlayer = new Audio();
// 设置初始音频
if (playerStore.playMusicUrl) {
audioPlayer.src = playerStore.playMusicUrl;
// 如果应该自动播放
if (playerStore.play) {
audioPlayer.play().catch((error) => {
console.error('播放失败:', error);
playerStore.setIsPlay(false);
});
}
}
// 添加事件监听
audioPlayer.addEventListener('timeupdate', handleTimeUpdate);
audioPlayer.addEventListener('loadeddata', handleLoadedData);
audioPlayer.addEventListener('ended', handleEnded);
audioPlayer.addEventListener('pause', () => playerStore.setIsPlay(false));
audioPlayer.addEventListener('play', () => playerStore.setIsPlay(true));
audioPlayer.addEventListener('error', handleError);
});
// 处理播放时间更新
const handleTimeUpdate = () => {
if (!audioPlayer) return;
currentTime.value = audioPlayer.currentTime;
playerProgress.value = currentTime.value;
// 保存播放进度到localStorage
const playProgress = {
songId: playerStore.currentSong.id,
progress: currentTime.value
};
localStorage.setItem('playProgress', JSON.stringify(playProgress));
};
// 处理音频加载完成
const handleLoadedData = () => {
if (!audioPlayer) return;
duration.value = audioPlayer.duration;
// 如果有保存的播放进度,恢复到对应位置
if (playerStore.savedPlayProgress !== undefined) {
audioPlayer.currentTime = playerStore.savedPlayProgress;
playerStore.savedPlayProgress = undefined;
}
};
// 处理播放结束
const handleEnded = () => {
// 根据播放模式决定下一步操作
if (playerStore.playMode === 1) {
// 单曲循环
if (audioPlayer) {
audioPlayer.currentTime = 0;
audioPlayer.play().catch((error) => {
console.error('重新播放失败:', error);
playerStore.setIsPlay(false);
});
}
} else {
// 列表循环或随机播放
playerStore.nextPlay();
}
};
// 处理播放错误
const handleError = (e: Event) => {
console.error('音频播放出错:', e);
if (audioPlayer?.error) {
console.error('错误码:', audioPlayer.error.code);
// 如果是B站音频可能链接过期尝试重新获取
if (playerStore.currentSong.source === 'bilibili' && playerStore.currentSong.bilibiliData) {
console.log('B站音频播放错误可能链接过期');
// 这里可以添加重新获取B站音频链接的逻辑
}
}
};
// 处理播放/暂停按钮点击
const handlePlayClick = () => {
if (!playerStore.currentSong.id) return;
playerStore.setPlayMusic(!playerStore.isPlay);
};
// 处理上一曲按钮点击
const handlePrevClick = () => {
playerStore.prevPlay();
};
// 处理下一曲按钮点击
const handleNextClick = () => {
playerStore.nextPlay();
};
// 处理进度条更新
const handleProgressUpdate = (value: number) => {
if (!audioPlayer) return;
audioPlayer.currentTime = value;
currentTime.value = value;
};
// 切换播放模式
const togglePlayMode = () => {
playerStore.togglePlayMode();
};
// 切换播放列表显示
const togglePlayList = () => {
showPlayList.value = !showPlayList.value;
// 这里可以添加显示播放列表的逻辑
};
// 打开详情播放页
const openDetailPlayer = () => {
if (!playerStore.currentSong.id) return;
playerStore.setMusicFull(true);
// 根据来源打开不同的详情页
if (playerStore.currentSong.source === 'bilibili') {
// 打开B站详情页
if (playerStore.currentSong.bilibiliData?.bvid) {
// 这里可以跳转到B站详情页或显示B站播放器组件
}
} else {
// 打开网易云音乐详情页
router.push({ path: '/lyric' });
}
};
// 格式化时间
const formatTime = (time: number) => {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
};
// 获取艺术家名称
const getArtistName = (song?: SongResult) => {
if (!song) return '';
// B站视频
if (song.source === 'bilibili' && song.song?.ar?.[0]) {
return song.song.ar[0].name || 'B站UP主';
}
// 网易云音乐
if (song.song?.artists) {
return song.song.artists.map((artist: any) => artist.name).join('/');
}
if (song.song?.ar) {
return song.song.ar.map((artist: any) => artist.name).join('/');
}
return '';
};
// 根据来源获取封面图
const getSourcePic = (song: SongResult) => {
if (!song || !song.picUrl) return '';
// B站视频
if (song.source === 'bilibili') {
return song.picUrl; // B站封面已经在创建时使用getBilibiliProxyUrl处理过
}
// 网易云音乐
return getImgUrl(song.picUrl, '150y150');
};
</script>
<style scoped lang="scss">
.music-bar-container {
@apply flex items-center justify-between px-4 h-20 w-full bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700;
&.playing {
.music-cover {
animation: rotate 20s linear infinite;
}
}
}
.music-bar-left {
@apply flex items-center;
width: 25%;
.music-cover {
@apply relative w-12 h-12 rounded-lg overflow-hidden flex items-center justify-center cursor-pointer;
.cover-image {
@apply w-full h-full object-cover;
}
&.loading {
animation-play-state: paused;
}
}
.music-info {
@apply ml-2 flex flex-col;
max-width: calc(100% - 56px);
.music-title {
@apply text-sm font-medium;
}
.music-artist {
@apply text-xs text-gray-500 dark:text-gray-400;
}
}
}
.music-bar-center {
@apply flex flex-col items-center;
width: 50%;
.player-controls {
@apply flex items-center justify-center gap-2 mb-2;
.control-button {
@apply text-lg;
&.play-button {
@apply text-xl;
}
}
}
.progress-container {
@apply flex items-center w-full;
.time-text {
@apply text-xs px-2 text-gray-500 dark:text-gray-400 whitespace-nowrap;
}
.progress-slider {
@apply flex-1;
}
}
}
.music-bar-right {
@apply flex items-center justify-end;
width: 25%;
.control-button {
@apply text-lg;
}
}
.ellipsis-text {
@apply whitespace-nowrap overflow-hidden text-ellipsis;
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -140,7 +140,7 @@ const dropdownY = ref(0);
const isDownloading = ref(false);
const openPlaylistDrawer = inject<(songId: number) => void>('openPlaylistDrawer');
const openPlaylistDrawer = inject<(songId: number | string) => void>('openPlaylistDrawer');
const { navigateToArtist } = useArtist();
@@ -285,7 +285,7 @@ const downloadMusic = async () => {
try {
isDownloading.value = true;
const data = (await getSongUrl(props.item.id, cloneDeep(props.item), true)) as any;
const data = (await getSongUrl(props.item.id as number, cloneDeep(props.item), true)) as any;
if (!data || !data.url) {
throw new Error(t('songItem.message.getUrlFailed'));
}
@@ -358,6 +358,7 @@ const imageLoad = async () => {
// 播放音乐 设置音乐详情 打开音乐底栏
const playMusicEvent = async (item: SongResult) => {
// 如果是当前正在播放的音乐,则切换播放/暂停状态
if (playMusic.value.id === item.id) {
if (play.value) {
playerStore.setPlayMusic(false);
@@ -368,23 +369,37 @@ const playMusicEvent = async (item: SongResult) => {
}
return;
}
await playerStore.setPlay(item);
playerStore.isPlay = true;
emits('play', item);
try {
// 使用store的setPlay方法该方法已经包含了B站视频URL处理逻辑
const result = await playerStore.setPlay(item);
if (!result) {
throw new Error('播放失败');
}
playerStore.isPlay = true;
emits('play', item);
} catch (error) {
console.error('播放出错:', error);
}
};
// 判断是否已收藏
const isFavorite = computed(() => {
return playerStore.favoriteList.includes(props.item.id);
// 将id转换为number兼容B站视频ID
const numericId = typeof props.item.id === 'string' ? parseInt(props.item.id, 10) : props.item.id;
return playerStore.favoriteList.includes(numericId);
});
// 切换收藏状态
const toggleFavorite = async (e: Event) => {
e.stopPropagation();
// 将id转换为number兼容B站视频ID
const numericId = typeof props.item.id === 'string' ? parseInt(props.item.id, 10) : props.item.id;
if (isFavorite.value) {
playerStore.removeFromFavorite(props.item.id);
playerStore.removeFromFavorite(numericId);
} else {
playerStore.addToFavorite(props.item.id);
playerStore.addToFavorite(numericId);
}
};

View File

@@ -2,6 +2,7 @@ import { createDiscreteApi } from 'naive-ui';
import { computed, nextTick, onUnmounted, ref, watch } from 'vue';
import i18n from '@/../i18n/renderer';
import { getBilibiliAudioUrl } from '@/api/bilibili';
import useIndexedDB from '@/hooks/IndexDBHook';
import { audioService } from '@/services/audioService';
import pinia, { usePlayerStore } from '@/store';
@@ -235,6 +236,29 @@ watch(
initialPosition = savedProgress.progress;
}
// 对于B站视频检查URL是否有效
if (playMusic.value.source === 'bilibili' && (!newVal || newVal === 'undefined')) {
console.log('B站视频URL无效尝试重新获取');
// 需要重新获取B站视频URL
if (playMusic.value.bilibiliData) {
try {
const proxyUrl = await getBilibiliAudioUrl(
playMusic.value.bilibiliData.bvid,
playMusic.value.bilibiliData.cid
);
// 设置URL到播放器状态
(playMusic.value as any).playMusicUrl = proxyUrl;
playerStore.playMusicUrl = proxyUrl;
newVal = proxyUrl;
} catch (error) {
console.error('获取B站音频URL失败:', error);
return;
}
}
}
// 播放新音频,传递是否应该播放的状态
const newSound = await audioService.play(newVal, playMusic.value, shouldPlay);
sound.value = newSound as Howl;

View File

@@ -12,7 +12,7 @@ import { getImageLinearBackground } from '@/utils/linearColor';
const musicHistory = useMusicHistory();
// 获取歌曲url
export const getSongUrl = async (id: number, songData: any, isDownloaded: boolean = false) => {
export const getSongUrl = async (id: any, songData: any, isDownloaded: boolean = false) => {
const { data } = await getMusicUrl(id, isDownloaded);
let url = '';
let songDetail = null;
@@ -247,7 +247,7 @@ export const useMusicListHook = () => {
};
// 异步加载歌词的方法
const loadLrcAsync = async (state: any, playMusicId: number) => {
const loadLrcAsync = async (state: any, playMusicId: any) => {
if (state.playMusic.lyric && state.playMusic.lyric.lrcTimeArray.length > 0) {
return;
}

View File

@@ -217,15 +217,15 @@ const scrollToPlayList = (val: boolean) => {
// 收藏功能
const isFavorite = computed(() => {
return playerStore.favoriteList.includes(playMusic.value.id);
return playerStore.favoriteList.includes(playMusic.value.id as number);
});
const toggleFavorite = () => {
console.log('isFavorite.value', isFavorite.value);
if (isFavorite.value) {
playerStore.removeFromFavorite(playMusic.value.id);
playerStore.removeFromFavorite(playMusic.value.id as number);
} else {
playerStore.addToFavorite(playMusic.value.id);
playerStore.addToFavorite(playMusic.value.id as number);
}
};

View File

@@ -176,6 +176,7 @@
<script lang="ts" setup>
import { useThrottleFn } from '@vueuse/core';
import { useMessage } from 'naive-ui';
import { computed, ref, useTemplateRef, watch } from 'vue';
import { useI18n } from 'vue-i18n';
@@ -204,6 +205,7 @@ import MusicFull from './MusicFull.vue';
const playerStore = usePlayerStore();
const settingsStore = useSettingsStore();
const { t } = useI18n();
const message = useMessage();
// 是否播放
const play = computed(() => playerStore.isPlay);
// 播放列表
@@ -315,33 +317,43 @@ const MusicFullRef = ref<any>(null);
// 播放暂停按钮事件
const playMusicEvent = async () => {
try {
// 检查是否有有效的音乐对象和 URL
if (!playMusic.value?.id || !playerStore.playMusicUrl) {
console.warn('No valid music or URL available');
playerStore.setPlay(playMusic.value);
// 检查是否有有效的音乐对象
if (!playMusic.value?.id) {
console.warn('没有有效的播放对象');
return;
}
// 当前处于播放状态 -> 暂停
if (play.value) {
// 暂停播放
if (audioService.getCurrentSound()) {
audioService.pause();
playerStore.setPlayMusic(false);
}
} else {
// 开始播放
if (audioService.getCurrentSound()) {
// 如果已经有音频实例,直接播放
audioService.play();
} else {
// 如果没有音频实例,重新创建并播放
await audioService.play(playerStore.playMusicUrl, playMusic.value);
}
return;
}
// 当前处于暂停状态 -> 播放
// 有音频实例,直接播放
if (audioService.getCurrentSound()) {
audioService.play();
playerStore.setPlayMusic(true);
return;
}
// 没有音频实例重新获取并播放包括重新获取B站视频URL
try {
// 复用当前播放对象但强制重新获取URL
const result = await playerStore.setPlay({ ...playMusic.value, playMusicUrl: undefined });
if (result) {
playerStore.setPlayMusic(true);
}
} catch (error) {
console.error('重新获取播放链接失败:', error);
message.error(t('player.playFailed'));
}
} catch (error) {
console.error('播放出错:', error);
playerStore.nextPlay();
message.error(t('player.playFailed'));
}
};
@@ -366,15 +378,22 @@ const scrollToPlayList = (val: boolean) => {
};
const isFavorite = computed(() => {
return playerStore.favoriteList.includes(playMusic.value.id);
// 将id转换为number兼容B站视频ID
const numericId =
typeof playMusic.value.id === 'string' ? parseInt(playMusic.value.id, 10) : playMusic.value.id;
return playerStore.favoriteList.includes(numericId);
});
const toggleFavorite = async (e: Event) => {
e.stopPropagation();
// 将id转换为number兼容B站视频ID
const numericId =
typeof playMusic.value.id === 'string' ? parseInt(playMusic.value.id, 10) : playMusic.value.id;
if (isFavorite.value) {
playerStore.removeFromFavorite(playMusic.value.id);
playerStore.removeFromFavorite(numericId);
} else {
playerStore.addToFavorite(playMusic.value.id);
playerStore.addToFavorite(numericId);
}
};

View File

@@ -42,6 +42,17 @@ const otherRouter = [
back: true
},
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')
}
];
export default otherRouter;

View File

@@ -2,6 +2,7 @@ import { cloneDeep } from 'lodash';
import { defineStore } from 'pinia';
import { computed, ref } from 'vue';
import { getBilibiliAudioUrl } from '@/api/bilibili';
import { getLikedList, getMusicLrc, getMusicUrl, getParsingMusicUrl } from '@/api/music';
import { useMusicHistory } from '@/hooks/MusicHistoryHook';
import type { ILyric, ILyricText, SongResult } from '@/type/music';
@@ -24,6 +25,8 @@ function getLocalStorageItem<T>(key: string, defaultValue: T): T {
}
}
// 提取公共函数获取B站视频URL
export const getSongUrl = async (
id: string | number,
songData: SongResult,
@@ -35,6 +38,18 @@ export const getSongUrl = async (
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
);
return songData.playMusicUrl;
} catch (error) {
console.error('重启后获取B站音频URL失败:', error);
return '';
}
}
return songData.playMusicUrl || '';
}
@@ -249,6 +264,27 @@ export const usePlayerStore = defineStore('player', () => {
const currentPlayListIndex = computed(() => playListIndex.value);
const handlePlayMusic = async (music: SongResult, isPlay: boolean = true) => {
// 处理B站视频确保URL有效
if (music.source === 'bilibili' && music.bilibiliData) {
try {
console.log('处理B站视频检查URL有效性');
// 清除之前的URL强制重新获取
music.playMusicUrl = undefined;
// 重新获取B站视频URL
if (music.bilibiliData.bvid && music.bilibiliData.cid) {
music.playMusicUrl = await getBilibiliAudioUrl(
music.bilibiliData.bvid,
music.bilibiliData.cid
);
console.log('获取B站URL成功:', music.playMusicUrl);
}
} catch (error) {
console.error('获取B站音频URL失败:', error);
throw error; // 向上抛出错误,让调用者处理
}
}
const updatedPlayMusic = await getSongDetail(music);
playMusic.value = updatedPlayMusic;
playMusicUrl.value = updatedPlayMusic.playMusicUrl as string;
@@ -284,9 +320,15 @@ export const usePlayerStore = defineStore('player', () => {
};
const setPlay = async (song: SongResult) => {
await handlePlayMusic(song);
localStorage.setItem('currentPlayMusic', JSON.stringify(playMusic.value));
localStorage.setItem('currentPlayMusicUrl', playMusicUrl.value);
try {
await handlePlayMusic(song);
localStorage.setItem('currentPlayMusic', JSON.stringify(playMusic.value));
localStorage.setItem('currentPlayMusicUrl', playMusicUrl.value);
return true;
} catch (error) {
console.error('设置播放失败:', error);
return false;
}
};
const setIsPlay = (value: boolean) => {
@@ -349,7 +391,18 @@ export const usePlayerStore = defineStore('player', () => {
}
playListIndex.value = nowPlayListIndex;
await handlePlayMusic(playList.value[playListIndex.value]);
// 获取下一首歌曲
const nextSong = playList.value[playListIndex.value];
// 如果是B站视频确保重新获取URL
if (nextSong.source === 'bilibili' && nextSong.bilibiliData) {
// 清除之前的URL确保重新获取
nextSong.playMusicUrl = undefined;
console.log('下一首是B站视频已清除URL强制重新获取');
}
await handlePlayMusic(nextSong);
};
const prevPlay = async () => {
@@ -359,7 +412,18 @@ export const usePlayerStore = defineStore('player', () => {
}
const nowPlayListIndex =
(playListIndex.value - 1 + playList.value.length) % playList.value.length;
await handlePlayMusic(playList.value[nowPlayListIndex]);
// 获取上一首歌曲
const prevSong = playList.value[nowPlayListIndex];
// 如果是B站视频确保重新获取URL
if (prevSong.source === 'bilibili' && prevSong.bilibiliData) {
// 清除之前的URL确保重新获取
prevSong.playMusicUrl = undefined;
console.log('上一首是B站视频已清除URL强制重新获取');
}
await handlePlayMusic(prevSong);
await fetchSongs(playList.value, playListIndex.value - 5, nowPlayListIndex);
};
@@ -392,8 +456,17 @@ export const usePlayerStore = defineStore('player', () => {
if (savedPlayMusic && Object.keys(savedPlayMusic).length > 0) {
try {
console.log('恢复上次播放的音乐:', savedPlayMusic.name);
console.log('settingStore.setData', settingStore.setData);
const isPlaying = settingStore.setData.autoPlay;
// 如果是B站视频确保播放URL能够在重启后正确恢复
if (savedPlayMusic.source === 'bilibili' && savedPlayMusic.bilibiliData) {
console.log('恢复B站视频播放', savedPlayMusic.bilibiliData);
// 清除之前可能存在的播放URL确保重新获取
savedPlayMusic.playMusicUrl = undefined;
}
await handlePlayMusic({ ...savedPlayMusic, playMusicUrl: undefined }, isPlaying);
if (savedProgress) {

View File

@@ -23,6 +23,9 @@ export interface SongResult {
canDislike?: boolean;
program?: any;
alg?: string;
ar: Artist[];
al: Album;
count: number;
playMusicUrl?: string;
playLoading?: boolean;
lyric?: ILyric;

View File

@@ -6,6 +6,9 @@ export interface IBilibiliSearchResult {
duration: number | string;
pubdate: number;
ctime: number;
author: string;
view: number;
danmaku: number;
owner: {
mid: number;
name: string;

View File

@@ -1,150 +1,174 @@
<template>
<n-drawer
v-model:show="showModal"
class="bilibili-player-modal"
:mask-closable="true"
:auto-focus="false"
:to="`#layout-main`"
preset="card"
height="80%"
placement="bottom"
>
<div class="bilibili-player-wrapper">
<div class="bilibili-player-header">
<div class="title">{{ videoDetail?.title || '加载中...' }}</div>
<div class="actions">
<n-button quaternary circle @click="closePlayer">
<template #icon>
<i class="ri-close-line"></i>
</template>
</n-button>
<div class="bilibili-player-page">
<n-scrollbar class="content-scrollbar">
<div class="content-wrapper">
<div v-if="isLoading" class="loading-wrapper">
<n-spin size="large" />
<p>听书加载中...</p>
</div>
</div>
<div v-if="isLoading" class="loading-wrapper">
<n-spin size="large" />
<p>听书加载中...</p>
</div>
<div v-else-if="errorMessage" class="error-wrapper">
<i class="ri-error-warning-line text-4xl text-red-500"></i>
<p>{{ errorMessage }}</p>
<n-button type="primary" @click="loadVideoSource">重试</n-button>
</div>
<div v-else-if="errorMessage" class="error-wrapper">
<i class="ri-error-warning-line text-4xl text-red-500"></i>
<p>{{ errorMessage }}</p>
<n-button type="primary" @click="loadVideoSource">重试</n-button>
</div>
<div v-else-if="videoDetail" class="bilibili-info-wrapper" :class="mainContentAnimation">
<div class="bilibili-cover">
<n-image
:src="getBilibiliProxyUrl(videoDetail.pic)"
class="cover-image"
preview-disabled
/>
<!-- 悬浮的播放按钮 -->
<div class="play-overlay">
<div class="play-icon-bg" @click="playCurrentAudio">
<i class="ri-play-fill"></i>
</div>
<!-- 固定在右下角的大型播放按钮 -->
<n-button
type="primary"
size="large"
class="corner-play-button"
:loading="partLoading"
@click="playCurrentAudio"
>
<template #icon>
<i class="ri-play-fill"></i>
</template>
立即播放
</n-button>
</div>
</div>
<div v-else-if="videoDetail" class="bilibili-info-wrapper">
<div class="bilibili-cover">
<n-image
:src="getBilibiliProxyUrl(videoDetail.pic)"
class="cover-image"
preview-disabled
/>
<div class="play-button" @click="playCurrentAudio">
<i class="ri-play-fill text-4xl"></i>
<div class="video-info">
<div class="title">{{ videoDetail?.title || '加载中...' }}</div>
<div class="author">
<i class="ri-user-line mr-1"></i>
<span>{{ videoDetail.owner?.name }}</span>
</div>
<div class="stats">
<span
><i class="ri-play-line mr-1"></i>{{ formatNumber(videoDetail.stat?.view) }}</span
>
<span
><i class="ri-chat-1-line mr-1"></i
>{{ formatNumber(videoDetail.stat?.danmaku) }}</span
>
<span
><i class="ri-thumb-up-line mr-1"></i
>{{ formatNumber(videoDetail.stat?.like) }}</span
>
</div>
<div class="description">
<p>{{ videoDetail.desc }}</p>
</div>
<div class="duration">
<p>总时长: {{ formatTotalDuration(videoDetail.duration) }}</p>
</div>
</div>
</div>
<div class="video-info">
<div class="author">
<i class="ri-user-line mr-1"></i>
<span>{{ videoDetail.owner?.name }}</span>
<div
v-if="videoDetail?.pages && videoDetail.pages.length > 1"
class="video-parts"
:class="partsListAnimation"
>
<div class="parts-title">
分P列表 ({{ videoDetail.pages.length }})
<n-spin v-if="partLoading" size="small" class="ml-2" />
</div>
<div class="stats">
<span><i class="ri-play-line mr-1"></i>{{ formatNumber(videoDetail.stat?.view) }}</span>
<span
><i class="ri-chat-1-line mr-1"></i
>{{ formatNumber(videoDetail.stat?.danmaku) }}</span
<div class="parts-list">
<n-button
v-for="page in videoDetail.pages"
:key="page.cid"
:type="isCurrentPlayingPage(page) ? 'primary' : 'default'"
:disabled="partLoading"
size="small"
class="part-item"
@click="switchPage(page)"
>
<span
><i class="ri-thumb-up-line mr-1"></i>{{ formatNumber(videoDetail.stat?.like) }}</span
>
</div>
<div class="description">
<p>{{ videoDetail.desc }}</p>
</div>
<div class="duration">
<p>总时长: {{ formatTotalDuration(videoDetail.duration) }}</p>
{{ page.part }}
</n-button>
</div>
</div>
</div>
<div v-if="videoDetail?.pages && videoDetail.pages.length > 1" class="video-parts">
<div class="parts-title">分P列表 ({{ videoDetail.pages.length }})</div>
<div class="parts-list">
<n-button
v-for="page in videoDetail.pages"
:key="page.cid"
:type="currentPage?.cid === page.cid ? 'primary' : 'default'"
size="small"
class="part-item"
@click="switchPage(page)"
>
{{ page.part }}
</n-button>
</div>
<!-- 底部留白 -->
<div class="pb-20"></div>
</div>
</div>
</n-drawer>
</n-scrollbar>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useMessage } from 'naive-ui';
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { getBilibiliPlayUrl, getBilibiliProxyUrl, getBilibiliVideoDetail } from '@/api/bilibili';
import { usePlayerStore } from '@/store/modules/player';
import type { SongResult } from '@/type/music';
import type { IBilibiliPage, IBilibiliVideoDetail } from '@/types/bilibili';
import { setAnimationClass } from '@/utils';
const props = defineProps<{
show: boolean;
bvid?: string;
}>();
const emit = defineEmits<{
(e: 'update:show', value: boolean): void;
(e: 'close'): void;
}>();
defineOptions({
name: 'BilibiliPlayer'
});
// 使
const route = useRoute();
const router = useRouter();
const message = useMessage();
const playerStore = usePlayerStore();
const isLoading = ref(true);
// bvid
const bvid = computed(() => route.params.bvid as string);
const isLoading = ref(true); //
const partLoading = ref(false); // PP
const errorMessage = ref('');
const videoDetail = ref<IBilibiliVideoDetail | null>(null);
const currentPage = ref<IBilibiliPage | null>(null);
const audioList = ref<SongResult[]>([]);
const showModal = computed({
get: () => props.show,
set: (value) => {
emit('update:show', value);
if (!value) {
emit('close');
}
//
const initialLoadDone = ref(false);
const mainContentAnimation = computed(() => {
if (!initialLoadDone.value) {
return setAnimationClass('animate__fadeInDown');
}
return '';
});
const partsListAnimation = computed(() => {
if (!initialLoadDone.value) {
return setAnimationClass('animate__fadeInUp');
}
return '';
});
// bvid
watch(
() => props.bvid,
() => bvid.value,
async (newBvid) => {
if (newBvid) {
// ID
initialLoadDone.value = false;
await loadVideoDetail(newBvid);
}
},
{ immediate: true }
);
watch(
() => props.show,
(newValue) => {
console.log('Modal show changed:', newValue);
if (newValue && props.bvid && !videoDetail.value) {
loadVideoDetail(props.bvid);
}
}
);
const closePlayer = () => {
showModal.value = false;
};
//
onMounted(async () => {
if (bvid.value) {
await loadVideoDetail(bvid.value);
} else {
message.error('视频ID无效');
router.back();
}
});
const loadVideoDetail = async (bvid: string) => {
if (!bvid) return;
@@ -176,12 +200,14 @@ const loadVideoDetail = async (bvid: string) => {
errorMessage.value = '获取视频详情失败';
} finally {
isLoading.value = false;
//
initialLoadDone.value = true;
}
};
const loadVideoSource = async () => {
if (!props.bvid || !currentPage.value?.cid) {
console.error('缺少必要参数:', { bvid: props.bvid, cid: currentPage.value?.cid });
if (!bvid.value || !currentPage.value?.cid) {
console.error('缺少必要参数:', { bvid: bvid.value, cid: currentPage.value?.cid });
return;
}
@@ -189,7 +215,7 @@ const loadVideoSource = async () => {
errorMessage.value = '';
try {
console.log('加载音频源:', props.bvid, currentPage.value.cid);
console.log('加载音频源:', bvid.value, currentPage.value.cid);
//
const tempAudio = createSongFromBilibiliVideo(); // URL
@@ -228,7 +254,7 @@ const loadVideoSource = async () => {
}
} as any,
bilibiliData: {
bvid: props.bvid,
bvid: bvid.value,
cid: page.cid
}
} as SongResult;
@@ -283,18 +309,22 @@ const createSongFromBilibiliVideo = (): SongResult => {
}
} as any,
bilibiliData: {
bvid: props.bvid,
bvid: bvid.value,
cid: currentPage.value.cid
}
} as SongResult;
};
const loadSongUrl = async (page: IBilibiliPage, songItem: SongResult) => {
if (songItem.playMusicUrl) return songItem; // URL
const loadSongUrl = async (
page: IBilibiliPage,
songItem: SongResult,
forceRefresh: boolean = false
) => {
if (songItem.playMusicUrl && !forceRefresh) return songItem; // URL
try {
console.log(`加载分P音频URL: ${page.part}, cid: ${page.cid}`);
const res = await getBilibiliPlayUrl(props.bvid!, page.cid);
const res = await getBilibiliPlayUrl(bvid.value, page.cid);
const playUrlData = res.data;
let url = '';
@@ -319,23 +349,32 @@ const loadSongUrl = async (page: IBilibiliPage, songItem: SongResult) => {
};
const switchPage = async (page: IBilibiliPage) => {
if (partLoading.value || currentPage.value?.cid === page.cid) return;
console.log('切换到分P:', page.part);
// UI
currentPage.value = page;
//
const audioItem = audioList.value.find((item) => item.bilibiliData?.cid === page.cid);
if (audioItem && !audioItem.playMusicUrl) {
// PURL
if (audioItem) {
//
try {
isLoading.value = true;
await loadSongUrl(page, audioItem);
partLoading.value = true;
// PURLURL
await loadSongUrl(page, audioItem, true);
//
playCurrentAudio();
} catch (error) {
console.error('切换分P时加载音频URL失败:', error);
errorMessage.value = '获取音频地址失败,请重试';
message.error('获取音频地址失败,请重试');
} finally {
isLoading.value = false;
partLoading.value = false;
}
} else {
console.error('未找到对应的音频项');
message.error('未找到对应的音频,请重试');
}
};
@@ -361,14 +400,12 @@ const playCurrentAudio = async () => {
console.log('准备播放当前选中的分P:', currentAudio.name);
try {
// PURL
if (!currentAudio.playMusicUrl) {
isLoading.value = true;
await loadSongUrl(currentPage.value!, currentAudio);
// PURLURL
partLoading.value = true;
await loadSongUrl(currentPage.value!, currentAudio, true);
if (!currentAudio.playMusicUrl) {
throw new Error('获取音频URL失败');
}
if (!currentAudio.playMusicUrl) {
throw new Error('获取音频URL失败');
}
// PURL
@@ -377,7 +414,7 @@ const playCurrentAudio = async () => {
const nextAudio = audioList.value[nextIndex];
const nextPage = videoDetail.value!.pages.find((p) => p.cid === nextAudio.bilibiliData?.cid);
if (nextPage && !nextAudio.playMusicUrl) {
if (nextPage) {
console.log('预加载下一个分P:', nextPage.part);
loadSongUrl(nextPage, nextAudio).catch((e) => console.warn('预加载下一个分P失败:', e));
}
@@ -390,13 +427,13 @@ const playCurrentAudio = async () => {
console.log('播放当前选中的分P:', currentAudio.name, '音频URL:', currentAudio.playMusicUrl);
playerStore.setPlayMusic(currentAudio);
//
closePlayer();
//
message.success('已开始播放');
} catch (error) {
console.error('播放音频失败:', error);
errorMessage.value = error instanceof Error ? error.message : '播放失败,请重试';
} finally {
isLoading.value = false;
partLoading.value = false;
}
};
@@ -423,27 +460,61 @@ const formatNumber = (num?: number) => {
}
return num.toString();
};
// P
const isCurrentPlayingPage = (page: IBilibiliPage) => {
// 使UI
const currentPlayingMusic = playerStore.playMusic as any;
if (
currentPlayingMusic &&
typeof currentPlayingMusic === 'object' &&
currentPlayingMusic.bilibiliData
) {
// cidPcid
return (
currentPlayingMusic.bilibiliData.cid === page.cid &&
currentPlayingMusic.bilibiliData.bvid === bvid.value
);
}
// 使UI
return currentPage.value?.cid === page.cid;
};
// P
watch(
() => playerStore.playMusic,
(newMusic: any) => {
if (
newMusic &&
typeof newMusic === 'object' &&
newMusic.bilibiliData &&
newMusic.bilibiliData.bvid === bvid.value
) {
// P
const playingPage = videoDetail.value?.pages?.find(
(p) => p.cid === newMusic.bilibiliData.cid
);
// UIUI
if (playingPage) {
currentPage.value = playingPage;
}
}
}
);
</script>
<style scoped lang="scss">
.bilibili-player-modal {
width: 90vw;
max-width: 1000px;
}
.bilibili-player-page {
@apply h-full flex flex-col;
.bilibili-player-wrapper {
@apply flex flex-col p-8 pt-4;
}
.bilibili-player-header {
@apply flex justify-between items-center mb-4;
.title {
@apply text-lg font-medium truncate;
.content-scrollbar {
@apply flex-1 overflow-hidden;
}
.actions {
@apply flex items-center;
.content-wrapper {
@apply flex flex-col p-4;
}
}
@@ -457,8 +528,28 @@ const formatNumber = (num?: number) => {
@apply w-full h-full object-cover;
}
.play-button {
@apply absolute inset-0 flex items-center justify-center bg-black/40 text-white opacity-0 hover:opacity-100 transition-opacity cursor-pointer;
.play-overlay {
@apply absolute inset-0;
.play-icon-bg {
@apply absolute inset-0 flex items-center justify-center bg-black/40 text-white opacity-0 hover:opacity-100 transition-opacity cursor-pointer;
i {
@apply text-4xl;
}
}
.corner-play-button {
@apply absolute right-3 bottom-3 shadow-lg flex items-center gap-1 px-4 py-1 text-sm transition-all duration-200;
&:hover {
@apply transform scale-110;
}
i {
@apply text-xl;
}
}
}
}
}
@@ -482,6 +573,10 @@ const formatNumber = (num?: number) => {
.video-info {
@apply flex-1 p-4 rounded-lg bg-gray-100 dark:bg-gray-800;
.title {
@apply text-lg font-medium mb-4 text-gray-900 dark:text-white;
}
.author {
@apply flex items-center text-sm mb-2;
}
@@ -505,11 +600,11 @@ const formatNumber = (num?: number) => {
@apply mt-4;
.parts-title {
@apply text-sm font-medium mb-2;
@apply text-sm font-medium mb-2 flex items-center;
}
.parts-list {
@apply flex flex-wrap gap-2 max-h-60 overflow-y-auto pb-20;
@apply flex flex-wrap gap-2 max-h-60 overflow-y-auto pb-4;
.part-item {
@apply text-xs mb-2;

View File

@@ -63,7 +63,7 @@
:class="setAnimationClass('animate__bounceInLeft')"
:style="getItemAnimationDelay(index)"
:selectable="isSelecting"
:selected="selectedSongs.includes(song.id)"
:selected="selectedSongs.includes(song.id as number)"
@play="handlePlay"
@select="handleSelect"
/>
@@ -319,7 +319,7 @@ const isIndeterminate = computed(() => {
// 处理全选/取消全选
const handleSelectAll = (checked: boolean) => {
if (checked) {
selectedSongs.value = favoriteSongs.value.map((song) => song.id);
selectedSongs.value = favoriteSongs.value.map((song) => song.id as number);
} else {
selectedSongs.value = [];
}

View File

@@ -71,7 +71,7 @@ const getHistorySongs = async () => {
const endIndex = startIndex + pageSize;
const currentPageItems = musicList.value.slice(startIndex, endIndex);
const currentIds = currentPageItems.map((item) => item.id);
const currentIds = currentPageItems.map((item) => item.id as number);
const res = await getMusicDetail(currentIds);
if (res.data.songs) {

View File

@@ -118,8 +118,6 @@
</template>
</div>
</n-layout>
<!-- 添加B站视频播放器组件 -->
<bilibili-player v-model:show="showBilibiliPlayer" :bvid="currentBvid" />
</div>
</template>
@@ -127,12 +125,11 @@
import { useDateFormat } from '@vueuse/core';
import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import { getBilibiliProxyUrl, searchBilibili } from '@/api/bilibili';
import { getHotSearch } from '@/api/home';
import { getSearch } from '@/api/search';
import BilibiliPlayer from '@/components/BilibiliPlayer.vue';
import BilibiliItem from '@/components/common/BilibiliItem.vue';
import SearchItem from '@/components/common/SearchItem.vue';
import SongItem from '@/components/common/SongItem.vue';
@@ -149,6 +146,7 @@ defineOptions({
const { t } = useI18n();
const route = useRoute();
const router = useRouter();
const playerStore = usePlayerStore();
const searchStore = useSearchStore();
@@ -216,10 +214,6 @@ onMounted(() => {
const hotKeyword = ref(route.query.keyword || t('search.title.searchList'));
// 显示B站视频播放器
const showBilibiliPlayer = ref(false);
const currentBvid = ref('');
const loadSearch = async (keywords: any, type: any = null, isLoadMore = false) => {
if (!keywords) return;
@@ -421,8 +415,8 @@ const handleSearchHistory = (item: { keyword: string; type: number }) => {
// 处理B站视频播放
const handlePlayBilibili = (item: IBilibiliSearchResult) => {
currentBvid.value = item.bvid;
showBilibiliPlayer.value = true;
// 使用路由导航到B站播放页面
router.push(`/bilibili/${item.bvid}`);
};
</script>