feat(api): 新增专辑和音乐API模块

新增AlbumApi模块,包含获取专辑详情功能
新增MusicApi模块,包含封面代理、音频探测、歌词获取、搜索、流媒体代理、音源切换等功能
完善PlayListApi模块的JSDoc注释和方法重命名

feat(player): 实现基于HTML5 Audio的播放器功能

使用HTML5 Audio元素替换原有的定时器机制
实现播放/暂停、进度控制、音量同步、时间更新等功能
添加播放结束自动下一首逻辑
优化播放器状态管理

refactor(song-table): 优化组件事件通信机制

移除props中的songs传递
改用emit事件方式通知父组件播放操作
提升组件复用性和解耦效果

feat(discover-view): 重构发现页面功能结构

整合歌单分类和推荐歌单布局
优化分类加载和展示逻辑
调整UI组件结构和样式
修复推荐歌单显示问题

feat(library-view): 优化个人库播放体验

集成歌曲表格的播放事件处理
实现点击播放和队列管理功能

feat(playlist-detail): 实现歌单详情播放功能

集成音乐API获取真实播放链接
实现播放链接获取失败的降级处理
优化歌单详情数据加载逻辑
添加播放全部功能

feat(search-view): 完善搜索结果播放功能

集成歌曲搜索结果的播放处理
实现搜索歌曲的队列播放功能
This commit is contained in:
Yuhang Wu 2026-08-12 11:31:47 +08:00
parent 98083829cb
commit 32ca1b4f74
11 changed files with 399 additions and 152 deletions

17
src/api/album.ts Normal file
View File

@ -0,0 +1,17 @@
import { get } from "@/utils/request"
import Source from "@/types/global"
const AlbumApi = {
/**
*
* @description ID
* @param id ID
* @param source
* @returns
*/
detail: (id: string, source: Source) => {
return get<any>(`/api/v1/album/detail?id=${id}&source=${source}`)
},
}
export default AlbumApi

101
src/api/music.ts Normal file
View File

@ -0,0 +1,101 @@
import { get } from "@/utils/request"
import Source from "@/types/global"
/** 音乐直链 */
export interface MusicUrl {
url: string
}
const MusicApi = {
/**
*
* @description QQ 403
* @param url URL ( urlencode)
* @param name ()
* @param artist ()
* @returns
*/
cover: (url: string, name?: string, artist?: string) => {
return get<any>(`/api/v1/music/cover?url=${url}&name=${name}&artist=${artist}`)
},
/**
*
* @description 访 Content-Range
* @param id ID
* @param source
* @param duration ()(kbps)
*/
inspect: (id: string, source: Source, duration?: string) => {
return get<any>(`/api/v1/music/inspect?id=${id}&source=${source}&duration=${duration}`)
},
/**
* JSON
* @description LRC JSON
* @param id ID
* @param source
* @returns
*/
lyric: (id: string, source: Source) => {
return get<any>(`/api/v1/music/lyric?id=${id}&source=${source}`)
},
/**
* LRC
* @description .lrc
* @param id ID
* @param source
* @param name ()
* @param artist ()
* @returns
*/
lyricFile: (id: string, source: Source, name?: string, artist?: string) => {
return get<any>(`/api/v1/music/lyric/file?id=${id}&source=${source}&name=${name}&artist=${artist}`)
},
/**
*
* @description
* @param keyword
* @param type 搜索类型: song ()playlist () album ()
* @param sources
* @returns
*/
search: (keyword: string, type: string, sources?: Source[]) => {
return get<any>(`/api/v1/music/search?q=${keyword}&type=${type}&sources=${sources}`)
},
/**
*
* @description Soda()
* @param id ID
* @param source
* @param name ()
* @param artist ()
* @returns
*/
stream: (id: string, source: Source, name?: string, artist?: string) => {
return get<any>(`/api/v1/music/stream?id=${id}&source=${source}&name=${name}&artist=${artist}`)
},
/**
*
* @description
* @param name
* @param source ()
* @param artist
* @param target
* @param duration ()
* @returns
*/
switch: (name: string, source: Source, artist?: string, target?: string, duration?: string) => {
return get<any>(`/api/v1/music/switch?name=${name}&source=${source}&artist=${artist}&target=${target}&duration=${duration}`)
},
/**
*
* @description header
* @param id ID
* @param source
* @returns
*/
url: (id: string, source: Source) => {
return get<MusicUrl>(`/api/v1/music/url?id=${id}&source=${source}`)
},
}
export default MusicApi

View File

@ -1,6 +1,7 @@
import { get } from "@/utils/request";
import Source from "@/types/global";
import { get } from "@/utils/request"
import Source from "@/types/global"
/** 歌单分类 */
interface PlayListCategory {
id: string
name: string
@ -9,6 +10,7 @@ interface PlayListCategory {
count: number
}
/** 分类歌单接口返回的数据结构 */
interface PlayListCategoryRes {
source: string
name: string
@ -81,39 +83,57 @@ interface PlayListSong {
}
/** 歌单摘要缓存:详情页通过它读取歌单标题、封面等元信息 */
const playListSummaryCache = new Map<string, PlayListSummary>();
const playListSummaryCache = new Map<string, PlayListSummary>()
export function cachePlayListSummary(summary: PlayListSummary) {
playListSummaryCache.set(summary.id, summary);
playListSummaryCache.set(summary.id, summary)
}
export function getCachedPlayListSummary(id: string): PlayListSummary | undefined {
return playListSummaryCache.get(id);
return playListSummaryCache.get(id)
}
const PlayListApi = {
// 获取歌单分类
getCategoryList: (sources?: Source[]) => {
return get<PlayListCategoryRes[]>(`/api/v1/playlist/categories?sources=${sources}`);
/**
*
* @param sources
* @returns
*/
categoryList: (sources?: Source[]) => {
return get<PlayListCategoryRes[]>(`/api/v1/playlist/categories?sources=${sources}`)
},
// 获取分类歌单
getCategoryPlayList: (
categoryId: string,
source?: Source,
page?: number,
limit?: number,
) => {
return get<CategoryPlayListRes>(
`/api/v1/playlist/category?source=${source}&category_id=${categoryId}&page=${page}&limit=${limit}`,
);
/**
*
* @description ID
* @param categoryId ID
* @param source
* @param page
* @param limit
* @returns
*/
categoryPlayList: (categoryId: string, source?: Source, page?: number, limit?: number) => {
return get<CategoryPlayListRes>(`/api/v1/playlist/category?source=${source}&category_id=${categoryId}&page=${page}&limit=${limit}`)
},
// 获取歌单详情
getPlayListDetail: (source: Source, id: string) => {
return get<PlayListSong[]>(
`/api/v1/playlist/detail?source=${source}&id=${id}`,
);
/**
*
* @param source
* @param id ID
* @returns
*/
detail: (source: Source, id: string) => {
return get<PlayListSong[]>(`/api/v1/playlist/detail?source=${source}&id=${id}`)
},
};
export default PlayListApi;
/**
*
* @description
* @param sources (使)
* @returns
*/
recommend: (sources?: Source[]) => {
return get<PlayListSummary[]>(`/api/v1/playlist/recommend?sources=${sources}`)
},
}
export default PlayListApi
export type { PlayListCategory, PlayListCategoryRes, PlayListSummary, CategoryPlayListRes, PlayListSong }

49
src/api/system.ts Normal file
View File

@ -0,0 +1,49 @@
import { get, post } from "@/utils/request"
import Source from "@/types/global"
const SystemApi = {
/**
* Cookies
* @description JSON Cookies
* @returns
*/
cookies: () => {
return get<any>(`/api/v1/system/cookies`)
},
/**
* Cookies
* @description JSON cookie
* @returns
*/
setCookies: (data: Record<string, string>) => {
return post<any>(`/api/v1/system/cookies`, data)
},
/**
*
* @description API
* @returns
*/
qrLoginSource: () => {
return get<any>(`/api/v1/system/qr_login/sources`)
},
/**
*
* @description 使 key cookies.json
* @param source
* @returns
*/
loginStatus: (source: Source) => {
return get<any>(`/api/v1/system/qr_login/${source}`)
},
/**
*
* @description URL key
* @param source
* @returns
*/
qrLogin: (source: Source) => {
return post<any>(`/api/v1/system/qr_login/${source}`)
},
}
export default SystemApi

View File

@ -1,5 +1,13 @@
<template>
<footer class="player-bar">
<audio
ref="audioRef"
class="audio-hidden"
preload="auto"
@timeupdate="handleTimeUpdate"
@ended="handleEnded"
/>
<div class="player-left">
<div
class="cover-mini"
@ -147,7 +155,7 @@ const liked = ref(new Set<string | number>());
const shuffle = ref(false);
const repeat = ref(false);
let timer: number | undefined;
const audioRef = ref<HTMLAudioElement | null>(null);
const current = computed(() => playerState.current);
@ -219,31 +227,78 @@ function openFullscreen() {
showFullscreen.value = true;
}
function handleTimeUpdate() {
if (audioRef.value) {
playerState.currentTime = audioRef.value.currentTime;
}
}
function handleEnded() {
next();
}
//
watch(
() => playerState.src,
(src) => {
const audio = audioRef.value;
if (!audio) return;
if (src) {
audio.src = src;
audio.load();
if (playerState.isPlaying) {
audio.play().catch(() => {});
}
} else {
audio.removeAttribute("src");
audio.load();
}
},
);
// /
watch(
() => playerState.isPlaying,
(playing) => {
const audio = audioRef.value;
if (!audio || !playerState.src) return;
if (playing) {
timer = window.setInterval(() => {
if (!playerState.current) return;
playerState.currentTime += 1;
if (playerState.currentTime >= playerState.current.duration) {
if (repeat.value) {
playerState.currentTime = 0;
} else {
next();
}
}
}, 1000);
} else if (timer !== undefined) {
window.clearInterval(timer);
timer = undefined;
audio.play().catch(() => {});
} else {
audio.pause();
}
},
);
//
watch(
() => playerState.volume,
(vol) => {
if (audioRef.value) {
audioRef.value.volume = vol / 100;
}
},
{ immediate: true },
);
// audio
watch(
() => playerState.currentTime,
(time) => {
const audio = audioRef.value;
if (!audio || !Number.isFinite(audio.duration)) return;
if (Math.abs(audio.currentTime - time) > 0.8) {
audio.currentTime = time;
}
},
);
onUnmounted(() => {
if (timer !== undefined) {
window.clearInterval(timer);
const audio = audioRef.value;
if (audio) {
audio.pause();
audio.removeAttribute("src");
audio.load();
}
});
</script>
@ -261,6 +316,10 @@ onUnmounted(() => {
border-top: 1px solid var(--app-border);
}
.audio-hidden {
display: none;
}
.player-left {
display: flex;
align-items: center;

View File

@ -51,11 +51,12 @@
<script setup lang="ts">
import { reactive } from "vue";
import type { Song } from "@/mocks/music";
import { playerState, playSong } from "@/stores/player";
import { playerState } from "@/stores/player";
import { formatTime } from "@/utils/time";
import AppIcon from "./AppIcon.vue";
const props = defineProps<{ songs: Song[] }>();
defineProps<{ songs: Song[] }>();
const emit = defineEmits<{ play: [song: Song] }>();
const liked = reactive(new Set<string | number>());
@ -73,7 +74,7 @@ function isCurrent(song: Song) {
}
function play(song: Song) {
playSong(song, props.songs);
emit("play", song);
}
</script>

View File

@ -7,6 +7,8 @@ interface PlayerState {
isPlaying: boolean;
currentTime: number;
volume: number;
/** 当前歌曲的播放直链 */
src: string;
}
/** 全局播放器状态(轻量版,后续可平滑迁移到 Pinia */
@ -16,11 +18,13 @@ export const playerState = reactive<PlayerState>({
isPlaying: false,
currentTime: 0,
volume: 70,
src: "",
});
export function playSong(song: Song, queue?: Song[]) {
export function playSong(song: Song, queue?: Song[], src = "") {
playerState.current = song;
playerState.currentTime = 0;
playerState.src = src;
if (queue && queue.length > 0) {
playerState.queue = [...queue];
}

View File

@ -16,19 +16,13 @@
<span class="section-sub">歌单分类</span>
</div>
<n-scrollbar x-scrollable class="category-scrollbar">
<n-radio-group
v-model:value="activeCategory"
name="playlist-category"
@update:value="playList"
>
<n-radio-button
v-for="category in categories"
:key="category.id"
:value="category.id"
:label="category.name"
/>
<n-radio-group v-model:value="activeCategory" name="playlist-category" @update:value="playList">
<n-radio-button v-for="category in categories" :key="category.id" :value="category.id" :label="category.name" />
</n-radio-group>
</n-scrollbar>
<div class="card-grid">
<MusicCard v-for="playlist in recommendedPlaylists" :key="playlist.id" :playlist="playlist" />
</div>
</section>
<section class="section">
@ -36,9 +30,9 @@
<h3>推荐歌单</h3>
<span class="section-sub">为你精选</span>
</div>
<div class="card-grid">
<!-- <div class="card-grid">
<MusicCard v-for="playlist in recommendedPlaylists" :key="playlist.id" :playlist="playlist" />
</div>
</div> -->
</section>
<section class="section">
@ -101,25 +95,25 @@
</template>
<script setup lang="ts">
import { NCarousel, useMessage } from "naive-ui";
import { useRouter } from "vue-router";
import AppIcon from "@/components/AppIcon.vue";
import MusicCard from "@/components/MusicCard.vue";
import { newSongs, rankLists, type Playlist } from "@/mocks/music";
import { playSong } from "@/stores/player";
import { proxyCover } from "@/utils/cover";
import { formatPlayCount } from "@/utils/format";
import { formatTime } from "@/utils/time";
import PlayListApi from "@/api/playlist";
import Source from "@/types/global";
import { cachePlayListSummary, type PlayListCategory } from "@/api/playlist";
import { NCarousel, useMessage } from "naive-ui"
import { useRouter } from "vue-router"
import AppIcon from "@/components/AppIcon.vue"
import MusicCard from "@/components/MusicCard.vue"
import { newSongs, rankLists, type Playlist } from "@/mocks/music"
import { playSong } from "@/stores/player"
import { proxyCover } from "@/utils/cover"
import { formatPlayCount } from "@/utils/format"
import { formatTime } from "@/utils/time"
import PlayListApi from "@/api/playlist"
import Source from "@/types/global"
import { cachePlayListSummary, type PlayListCategory } from "@/api/playlist"
const message = useMessage();
const router = useRouter();
const message = useMessage()
const router = useRouter()
const categories = ref<PlayListCategory[]>([]);
const categories = ref<PlayListCategory[]>([])
const activeCategory = ref<string>("")
const recommendedPlaylists = ref<Playlist[]>([]);
const recommendedPlaylists = ref<Playlist[]>([])
const banners = [
{
@ -137,27 +131,27 @@ const banners = [
sub: "全站播放量最高的 100 首歌",
gradient: "linear-gradient(120deg, #f59e0b 0%, #ef4444 55%, #f97316 100%)",
},
];
]
function playNewSong(songIndex: number) {
const song = newSongs[songIndex];
playSong(song, newSongs);
message.success(`正在播放:${song.title}`);
const song = newSongs[songIndex]
playSong(song, newSongs)
message.success(`正在播放:${song.title}`)
}
const list = async () => {
const res = await PlayListApi.getCategoryList([Source.KuGou]);
categories.value = res[0].categories;
const res = await PlayListApi.categoryList([Source.KuGou])
categories.value = res[0].categories
if (categories.value.length > 0) {
activeCategory.value = categories.value[1].id;
await playList(activeCategory.value);
activeCategory.value = categories.value[1].id
await playList(activeCategory.value)
}
};
}
const playList = async (categoryId: string) => {
const res = await PlayListApi.getCategoryPlayList(categoryId, Source.KuGou, 1, 30);
const res = await PlayListApi.categoryPlayList(categoryId, Source.KuGou, 1, 30)
recommendedPlaylists.value = res.playlists.map((p) => {
cachePlayListSummary(p);
cachePlayListSummary(p)
return {
id: p.id,
name: p.name,
@ -166,13 +160,13 @@ const playList = async (categoryId: string) => {
tags: [],
playCount: formatPlayCount(p.play_count),
songs: [],
};
});
}
})
}
onMounted(() => {
list();
});
list()
})
</script>
<style scoped>
@ -247,6 +241,7 @@ onMounted(() => {
.card-grid {
display: grid;
margin-top: 20px;
grid-template-columns: repeat(auto-fill, minmax(clamp(140px, 14vw, 180px), 1fr));
gap: 20px 18px;
}

View File

@ -20,7 +20,11 @@
播放全部
</n-button>
</div>
<SongTable v-if="tab.songs.length > 0" :songs="tab.songs" />
<SongTable
v-if="tab.songs.length > 0"
:songs="tab.songs"
@play="(song) => playSong(song, tab.songs)"
/>
<n-empty
v-else
class="empty"

View File

@ -16,18 +16,11 @@
</p>
<p v-if="headerDesc" class="desc">{{ headerDesc }}</p>
<div v-if="tags.length" class="tags">
<n-tag
v-for="tag in tags"
:key="tag"
size="small"
:bordered="false"
>
<n-tag v-for="tag in tags" :key="tag" size="small" :bordered="false">
{{ tag }}
</n-tag>
</div>
<p class="stats">
{{ songs.length }} · {{ headerPlayCount }} 次播放
</p>
<p class="stats">{{ songs.length }} · {{ headerPlayCount }} 次播放</p>
<div class="actions">
<n-button type="primary" round size="large" :disabled="songs.length === 0" @click="playAll">
<AppIcon name="play" :size="16" class="btn-icon" />
@ -43,62 +36,50 @@
<div class="song-section">
<n-spin v-if="loading" class="song-loading" />
<SongTable v-else :songs="songs" />
<SongTable v-else :songs="songs" @play="playWithUrl" />
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { NButton, NSpin, NTag, useMessage } from "naive-ui";
import { useRouter } from "vue-router";
import AppIcon from "@/components/AppIcon.vue";
import SongTable from "@/components/SongTable.vue";
import PlayListApi, { getCachedPlayListSummary } from "@/api/playlist";
import { playlists, type Song } from "@/mocks/music";
import { playSong } from "@/stores/player";
import { displayCover } from "@/utils/cover";
import { formatPlayCount } from "@/utils/format";
import Source from "@/types/global";
import { computed, onMounted, ref } from "vue"
import { NButton, NSpin, NTag, useMessage } from "naive-ui"
import { useRouter } from "vue-router"
import AppIcon from "@/components/AppIcon.vue"
import SongTable from "@/components/SongTable.vue"
import PlayListApi, { getCachedPlayListSummary } from "@/api/playlist"
import { playlists, type Song } from "@/mocks/music"
import { playSong } from "@/stores/player"
import { displayCover } from "@/utils/cover"
import { formatPlayCount } from "@/utils/format"
import Source from "@/types/global"
import MusicApi from "@/api/music"
const props = defineProps<{ id: string }>();
const message = useMessage();
const router = useRouter();
const props = defineProps<{ id: string }>()
const message = useMessage()
const router = useRouter()
const songs = ref<Song[]>([]);
const loading = ref(true);
const songs = ref<Song[]>([])
const loading = ref(true)
const summary = computed(() => getCachedPlayListSummary(props.id));
const mockPlaylist = computed(() =>
playlists.find((item) => item.id === props.id),
);
const summary = computed(() => getCachedPlayListSummary(props.id))
const mockPlaylist = computed(() => playlists.find((item) => item.id === props.id))
const headerName = computed(
() => summary.value?.name ?? mockPlaylist.value?.name ?? "歌单详情",
);
const headerCreator = computed(
() => summary.value?.creator ?? "YwYMusic 官方",
);
const headerDesc = computed(
() => summary.value?.description ?? mockPlaylist.value?.desc ?? "",
);
const tags = computed(() => mockPlaylist.value?.tags ?? []);
const headerName = computed(() => summary.value?.name ?? mockPlaylist.value?.name ?? "歌单详情")
const headerCreator = computed(() => summary.value?.creator ?? "YwYMusic 官方")
const headerDesc = computed(() => summary.value?.description ?? mockPlaylist.value?.desc ?? "")
const tags = computed(() => mockPlaylist.value?.tags ?? [])
const headerPlayCount = computed(() =>
summary.value
? formatPlayCount(summary.value.play_count)
: (mockPlaylist.value?.playCount ?? String(songs.value.length)),
);
summary.value ? formatPlayCount(summary.value.play_count) : (mockPlaylist.value?.playCount ?? String(songs.value.length)),
)
const headerCover = computed(() => {
const cover =
summary.value?.cover ?? mockPlaylist.value?.cover ?? songs.value[0]?.cover ?? "";
return cover
? displayCover(cover)
: "linear-gradient(135deg, #4338ca 0%, #7c3aed 100%)";
});
const cover = summary.value?.cover ?? mockPlaylist.value?.cover ?? songs.value[0]?.cover ?? ""
return cover ? displayCover(cover) : "linear-gradient(135deg, #4338ca 0%, #7c3aed 100%)"
})
onMounted(async () => {
try {
const res = await PlayListApi.getPlayListDetail(Source.KuGou, props.id);
const res = await PlayListApi.detail(Source.KuGou, props.id)
songs.value = res.map((song) => ({
id: song.id,
title: song.name,
@ -106,24 +87,35 @@ onMounted(async () => {
album: song.album,
duration: song.duration,
cover: displayCover(song.cover),
}));
}))
} catch {
// mock
const mock = playlists.find((item) => item.id === props.id);
if (mock) songs.value = mock.songs;
const mock = playlists.find((item) => item.id === props.id)
if (mock) songs.value = mock.songs
} finally {
loading.value = false;
loading.value = false
}
});
})
function playAll() {
if (songs.value.length === 0) return;
playSong(songs.value[0], songs.value);
message.success(`正在播放歌单:${headerName.value}`);
if (songs.value.length === 0) return
playWithUrl(songs.value[0], songs.value)
message.success(`正在播放歌单:${headerName.value}`)
}
async function playWithUrl(song: Song, queue?: Song[]) {
try {
const { url } = await MusicApi.url(String(song.id), Source.KuGou)
playSong(song, queue, url)
} catch {
// MusicApi.stream
playSong(song, queue)
}
message.success(`正在播放:${song.title}`)
}
function favorite() {
message.success(`已收藏「${headerName.value}`);
message.success(`已收藏「${headerName.value}`)
}
</script>

View File

@ -37,7 +37,11 @@
<n-tabs v-model:value="activeTab" type="line" animated>
<n-tab-pane name="song" :tab="`单曲 ${songResults.length}`">
<div class="result-body">
<SongTable v-if="songResults.length > 0" :songs="songResults" />
<SongTable
v-if="songResults.length > 0"
:songs="songResults"
@play="(song) => playSong(song, songResults)"
/>
<n-empty v-else class="empty" description="没有找到相关单曲" />
</div>
</n-tab-pane>
@ -65,6 +69,7 @@ import AppIcon from "@/components/AppIcon.vue";
import MusicCard from "@/components/MusicCard.vue";
import SongTable from "@/components/SongTable.vue";
import { allSongs, hotKeywords, playlists } from "@/mocks/music";
import { playSong } from "@/stores/player";
const message = useMessage();
const keyword = ref("");