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:
parent
98083829cb
commit
32ca1b4f74
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { get } from "@/utils/request";
|
import { get } from "@/utils/request"
|
||||||
import Source from "@/types/global";
|
import Source from "@/types/global"
|
||||||
|
|
||||||
|
/** 歌单分类 */
|
||||||
interface PlayListCategory {
|
interface PlayListCategory {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
|
|
@ -9,6 +10,7 @@ interface PlayListCategory {
|
||||||
count: number
|
count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 分类歌单接口返回的数据结构 */
|
||||||
interface PlayListCategoryRes {
|
interface PlayListCategoryRes {
|
||||||
source: string
|
source: string
|
||||||
name: 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) {
|
export function cachePlayListSummary(summary: PlayListSummary) {
|
||||||
playListSummaryCache.set(summary.id, summary);
|
playListSummaryCache.set(summary.id, summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCachedPlayListSummary(id: string): PlayListSummary | undefined {
|
export function getCachedPlayListSummary(id: string): PlayListSummary | undefined {
|
||||||
return playListSummaryCache.get(id);
|
return playListSummaryCache.get(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const PlayListApi = {
|
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,
|
* @description 按平台和分类 ID 分页获取歌单。
|
||||||
source?: Source,
|
* @param categoryId 分类 ID
|
||||||
page?: number,
|
* @param source 音乐来源平台
|
||||||
limit?: number,
|
* @param page 页码
|
||||||
) => {
|
* @param limit 每页数量
|
||||||
return get<CategoryPlayListRes>(
|
* @returns
|
||||||
`/api/v1/playlist/category?source=${source}&category_id=${categoryId}&page=${page}&limit=${limit}`,
|
*/
|
||||||
);
|
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[]>(
|
* @param source 歌单所属平台
|
||||||
`/api/v1/playlist/detail?source=${source}&id=${id}`,
|
* @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 }
|
export type { PlayListCategory, PlayListCategoryRes, PlayListSummary, CategoryPlayListRes, PlayListSong }
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
<template>
|
<template>
|
||||||
<footer class="player-bar">
|
<footer class="player-bar">
|
||||||
|
<audio
|
||||||
|
ref="audioRef"
|
||||||
|
class="audio-hidden"
|
||||||
|
preload="auto"
|
||||||
|
@timeupdate="handleTimeUpdate"
|
||||||
|
@ended="handleEnded"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="player-left">
|
<div class="player-left">
|
||||||
<div
|
<div
|
||||||
class="cover-mini"
|
class="cover-mini"
|
||||||
|
|
@ -147,7 +155,7 @@ const liked = ref(new Set<string | number>());
|
||||||
const shuffle = ref(false);
|
const shuffle = ref(false);
|
||||||
const repeat = ref(false);
|
const repeat = ref(false);
|
||||||
|
|
||||||
let timer: number | undefined;
|
const audioRef = ref<HTMLAudioElement | null>(null);
|
||||||
|
|
||||||
const current = computed(() => playerState.current);
|
const current = computed(() => playerState.current);
|
||||||
|
|
||||||
|
|
@ -219,31 +227,78 @@ function openFullscreen() {
|
||||||
showFullscreen.value = true;
|
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(
|
watch(
|
||||||
() => playerState.isPlaying,
|
() => playerState.isPlaying,
|
||||||
(playing) => {
|
(playing) => {
|
||||||
|
const audio = audioRef.value;
|
||||||
|
if (!audio || !playerState.src) return;
|
||||||
if (playing) {
|
if (playing) {
|
||||||
timer = window.setInterval(() => {
|
audio.play().catch(() => {});
|
||||||
if (!playerState.current) return;
|
|
||||||
playerState.currentTime += 1;
|
|
||||||
if (playerState.currentTime >= playerState.current.duration) {
|
|
||||||
if (repeat.value) {
|
|
||||||
playerState.currentTime = 0;
|
|
||||||
} else {
|
} else {
|
||||||
next();
|
audio.pause();
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 音量同步
|
||||||
|
watch(
|
||||||
|
() => playerState.volume,
|
||||||
|
(vol) => {
|
||||||
|
if (audioRef.value) {
|
||||||
|
audioRef.value.volume = vol / 100;
|
||||||
}
|
}
|
||||||
}, 1000);
|
},
|
||||||
} else if (timer !== undefined) {
|
{ immediate: true },
|
||||||
window.clearInterval(timer);
|
);
|
||||||
timer = undefined;
|
|
||||||
|
// 进度跳转(拖动进度条时同步到 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(() => {
|
onUnmounted(() => {
|
||||||
if (timer !== undefined) {
|
const audio = audioRef.value;
|
||||||
window.clearInterval(timer);
|
if (audio) {
|
||||||
|
audio.pause();
|
||||||
|
audio.removeAttribute("src");
|
||||||
|
audio.load();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
@ -261,6 +316,10 @@ onUnmounted(() => {
|
||||||
border-top: 1px solid var(--app-border);
|
border-top: 1px solid var(--app-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.audio-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.player-left {
|
.player-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -51,11 +51,12 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive } from "vue";
|
import { reactive } from "vue";
|
||||||
import type { Song } from "@/mocks/music";
|
import type { Song } from "@/mocks/music";
|
||||||
import { playerState, playSong } from "@/stores/player";
|
import { playerState } from "@/stores/player";
|
||||||
import { formatTime } from "@/utils/time";
|
import { formatTime } from "@/utils/time";
|
||||||
import AppIcon from "./AppIcon.vue";
|
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>());
|
const liked = reactive(new Set<string | number>());
|
||||||
|
|
||||||
|
|
@ -73,7 +74,7 @@ function isCurrent(song: Song) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function play(song: Song) {
|
function play(song: Song) {
|
||||||
playSong(song, props.songs);
|
emit("play", song);
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ interface PlayerState {
|
||||||
isPlaying: boolean;
|
isPlaying: boolean;
|
||||||
currentTime: number;
|
currentTime: number;
|
||||||
volume: number;
|
volume: number;
|
||||||
|
/** 当前歌曲的播放直链 */
|
||||||
|
src: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 全局播放器状态(轻量版,后续可平滑迁移到 Pinia) */
|
/** 全局播放器状态(轻量版,后续可平滑迁移到 Pinia) */
|
||||||
|
|
@ -16,11 +18,13 @@ export const playerState = reactive<PlayerState>({
|
||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
volume: 70,
|
volume: 70,
|
||||||
|
src: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
export function playSong(song: Song, queue?: Song[]) {
|
export function playSong(song: Song, queue?: Song[], src = "") {
|
||||||
playerState.current = song;
|
playerState.current = song;
|
||||||
playerState.currentTime = 0;
|
playerState.currentTime = 0;
|
||||||
|
playerState.src = src;
|
||||||
if (queue && queue.length > 0) {
|
if (queue && queue.length > 0) {
|
||||||
playerState.queue = [...queue];
|
playerState.queue = [...queue];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,19 +16,13 @@
|
||||||
<span class="section-sub">歌单分类</span>
|
<span class="section-sub">歌单分类</span>
|
||||||
</div>
|
</div>
|
||||||
<n-scrollbar x-scrollable class="category-scrollbar">
|
<n-scrollbar x-scrollable class="category-scrollbar">
|
||||||
<n-radio-group
|
<n-radio-group v-model:value="activeCategory" name="playlist-category" @update:value="playList">
|
||||||
v-model:value="activeCategory"
|
<n-radio-button v-for="category in categories" :key="category.id" :value="category.id" :label="category.name" />
|
||||||
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-radio-group>
|
||||||
</n-scrollbar>
|
</n-scrollbar>
|
||||||
|
<div class="card-grid">
|
||||||
|
<MusicCard v-for="playlist in recommendedPlaylists" :key="playlist.id" :playlist="playlist" />
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section">
|
<section class="section">
|
||||||
|
|
@ -36,9 +30,9 @@
|
||||||
<h3>推荐歌单</h3>
|
<h3>推荐歌单</h3>
|
||||||
<span class="section-sub">为你精选</span>
|
<span class="section-sub">为你精选</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-grid">
|
<!-- <div class="card-grid">
|
||||||
<MusicCard v-for="playlist in recommendedPlaylists" :key="playlist.id" :playlist="playlist" />
|
<MusicCard v-for="playlist in recommendedPlaylists" :key="playlist.id" :playlist="playlist" />
|
||||||
</div>
|
</div> -->
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section">
|
<section class="section">
|
||||||
|
|
@ -101,25 +95,25 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NCarousel, useMessage } from "naive-ui";
|
import { NCarousel, useMessage } from "naive-ui"
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router"
|
||||||
import AppIcon from "@/components/AppIcon.vue";
|
import AppIcon from "@/components/AppIcon.vue"
|
||||||
import MusicCard from "@/components/MusicCard.vue";
|
import MusicCard from "@/components/MusicCard.vue"
|
||||||
import { newSongs, rankLists, type Playlist } from "@/mocks/music";
|
import { newSongs, rankLists, type Playlist } from "@/mocks/music"
|
||||||
import { playSong } from "@/stores/player";
|
import { playSong } from "@/stores/player"
|
||||||
import { proxyCover } from "@/utils/cover";
|
import { proxyCover } from "@/utils/cover"
|
||||||
import { formatPlayCount } from "@/utils/format";
|
import { formatPlayCount } from "@/utils/format"
|
||||||
import { formatTime } from "@/utils/time";
|
import { formatTime } from "@/utils/time"
|
||||||
import PlayListApi from "@/api/playlist";
|
import PlayListApi from "@/api/playlist"
|
||||||
import Source from "@/types/global";
|
import Source from "@/types/global"
|
||||||
import { cachePlayListSummary, type PlayListCategory } from "@/api/playlist";
|
import { cachePlayListSummary, type PlayListCategory } from "@/api/playlist"
|
||||||
|
|
||||||
const message = useMessage();
|
const message = useMessage()
|
||||||
const router = useRouter();
|
const router = useRouter()
|
||||||
|
|
||||||
const categories = ref<PlayListCategory[]>([]);
|
const categories = ref<PlayListCategory[]>([])
|
||||||
const activeCategory = ref<string>("")
|
const activeCategory = ref<string>("")
|
||||||
const recommendedPlaylists = ref<Playlist[]>([]);
|
const recommendedPlaylists = ref<Playlist[]>([])
|
||||||
|
|
||||||
const banners = [
|
const banners = [
|
||||||
{
|
{
|
||||||
|
|
@ -137,27 +131,27 @@ const banners = [
|
||||||
sub: "全站播放量最高的 100 首歌",
|
sub: "全站播放量最高的 100 首歌",
|
||||||
gradient: "linear-gradient(120deg, #f59e0b 0%, #ef4444 55%, #f97316 100%)",
|
gradient: "linear-gradient(120deg, #f59e0b 0%, #ef4444 55%, #f97316 100%)",
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
|
|
||||||
function playNewSong(songIndex: number) {
|
function playNewSong(songIndex: number) {
|
||||||
const song = newSongs[songIndex];
|
const song = newSongs[songIndex]
|
||||||
playSong(song, newSongs);
|
playSong(song, newSongs)
|
||||||
message.success(`正在播放:${song.title}`);
|
message.success(`正在播放:${song.title}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const list = async () => {
|
const list = async () => {
|
||||||
const res = await PlayListApi.getCategoryList([Source.KuGou]);
|
const res = await PlayListApi.categoryList([Source.KuGou])
|
||||||
categories.value = res[0].categories;
|
categories.value = res[0].categories
|
||||||
if (categories.value.length > 0) {
|
if (categories.value.length > 0) {
|
||||||
activeCategory.value = categories.value[1].id;
|
activeCategory.value = categories.value[1].id
|
||||||
await playList(activeCategory.value);
|
await playList(activeCategory.value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const playList = async (categoryId: string) => {
|
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) => {
|
recommendedPlaylists.value = res.playlists.map((p) => {
|
||||||
cachePlayListSummary(p);
|
cachePlayListSummary(p)
|
||||||
return {
|
return {
|
||||||
id: p.id,
|
id: p.id,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
|
|
@ -166,13 +160,13 @@ const playList = async (categoryId: string) => {
|
||||||
tags: [],
|
tags: [],
|
||||||
playCount: formatPlayCount(p.play_count),
|
playCount: formatPlayCount(p.play_count),
|
||||||
songs: [],
|
songs: [],
|
||||||
};
|
}
|
||||||
});
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
list();
|
list()
|
||||||
});
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -247,6 +241,7 @@ onMounted(() => {
|
||||||
|
|
||||||
.card-grid {
|
.card-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
margin-top: 20px;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(clamp(140px, 14vw, 180px), 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(clamp(140px, 14vw, 180px), 1fr));
|
||||||
gap: 20px 18px;
|
gap: 20px 18px;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,11 @@
|
||||||
播放全部
|
播放全部
|
||||||
</n-button>
|
</n-button>
|
||||||
</div>
|
</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
|
<n-empty
|
||||||
v-else
|
v-else
|
||||||
class="empty"
|
class="empty"
|
||||||
|
|
|
||||||
|
|
@ -16,18 +16,11 @@
|
||||||
</p>
|
</p>
|
||||||
<p v-if="headerDesc" class="desc">{{ headerDesc }}</p>
|
<p v-if="headerDesc" class="desc">{{ headerDesc }}</p>
|
||||||
<div v-if="tags.length" class="tags">
|
<div v-if="tags.length" class="tags">
|
||||||
<n-tag
|
<n-tag v-for="tag in tags" :key="tag" size="small" :bordered="false">
|
||||||
v-for="tag in tags"
|
|
||||||
:key="tag"
|
|
||||||
size="small"
|
|
||||||
:bordered="false"
|
|
||||||
>
|
|
||||||
{{ tag }}
|
{{ tag }}
|
||||||
</n-tag>
|
</n-tag>
|
||||||
</div>
|
</div>
|
||||||
<p class="stats">
|
<p class="stats">{{ songs.length }} 首 · {{ headerPlayCount }} 次播放</p>
|
||||||
{{ songs.length }} 首 · {{ headerPlayCount }} 次播放
|
|
||||||
</p>
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<n-button type="primary" round size="large" :disabled="songs.length === 0" @click="playAll">
|
<n-button type="primary" round size="large" :disabled="songs.length === 0" @click="playAll">
|
||||||
<AppIcon name="play" :size="16" class="btn-icon" />
|
<AppIcon name="play" :size="16" class="btn-icon" />
|
||||||
|
|
@ -43,62 +36,50 @@
|
||||||
|
|
||||||
<div class="song-section">
|
<div class="song-section">
|
||||||
<n-spin v-if="loading" class="song-loading" />
|
<n-spin v-if="loading" class="song-loading" />
|
||||||
<SongTable v-else :songs="songs" />
|
<SongTable v-else :songs="songs" @play="playWithUrl" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref } from "vue";
|
import { computed, onMounted, ref } from "vue"
|
||||||
import { NButton, NSpin, NTag, useMessage } from "naive-ui";
|
import { NButton, NSpin, NTag, useMessage } from "naive-ui"
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router"
|
||||||
import AppIcon from "@/components/AppIcon.vue";
|
import AppIcon from "@/components/AppIcon.vue"
|
||||||
import SongTable from "@/components/SongTable.vue";
|
import SongTable from "@/components/SongTable.vue"
|
||||||
import PlayListApi, { getCachedPlayListSummary } from "@/api/playlist";
|
import PlayListApi, { getCachedPlayListSummary } from "@/api/playlist"
|
||||||
import { playlists, type Song } from "@/mocks/music";
|
import { playlists, type Song } from "@/mocks/music"
|
||||||
import { playSong } from "@/stores/player";
|
import { playSong } from "@/stores/player"
|
||||||
import { displayCover } from "@/utils/cover";
|
import { displayCover } from "@/utils/cover"
|
||||||
import { formatPlayCount } from "@/utils/format";
|
import { formatPlayCount } from "@/utils/format"
|
||||||
import Source from "@/types/global";
|
import Source from "@/types/global"
|
||||||
|
import MusicApi from "@/api/music"
|
||||||
|
|
||||||
const props = defineProps<{ id: string }>();
|
const props = defineProps<{ id: string }>()
|
||||||
const message = useMessage();
|
const message = useMessage()
|
||||||
const router = useRouter();
|
const router = useRouter()
|
||||||
|
|
||||||
const songs = ref<Song[]>([]);
|
const songs = ref<Song[]>([])
|
||||||
const loading = ref(true);
|
const loading = ref(true)
|
||||||
|
|
||||||
const summary = computed(() => getCachedPlayListSummary(props.id));
|
const summary = computed(() => getCachedPlayListSummary(props.id))
|
||||||
const mockPlaylist = computed(() =>
|
const mockPlaylist = computed(() => playlists.find((item) => item.id === props.id))
|
||||||
playlists.find((item) => item.id === props.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
const headerName = computed(
|
const headerName = computed(() => summary.value?.name ?? mockPlaylist.value?.name ?? "歌单详情")
|
||||||
() => summary.value?.name ?? mockPlaylist.value?.name ?? "歌单详情",
|
const headerCreator = computed(() => summary.value?.creator ?? "YwYMusic 官方")
|
||||||
);
|
const headerDesc = computed(() => summary.value?.description ?? mockPlaylist.value?.desc ?? "")
|
||||||
const headerCreator = computed(
|
const tags = computed(() => mockPlaylist.value?.tags ?? [])
|
||||||
() => summary.value?.creator ?? "YwYMusic 官方",
|
|
||||||
);
|
|
||||||
const headerDesc = computed(
|
|
||||||
() => summary.value?.description ?? mockPlaylist.value?.desc ?? "",
|
|
||||||
);
|
|
||||||
const tags = computed(() => mockPlaylist.value?.tags ?? []);
|
|
||||||
const headerPlayCount = computed(() =>
|
const headerPlayCount = computed(() =>
|
||||||
summary.value
|
summary.value ? formatPlayCount(summary.value.play_count) : (mockPlaylist.value?.playCount ?? String(songs.value.length)),
|
||||||
? formatPlayCount(summary.value.play_count)
|
)
|
||||||
: (mockPlaylist.value?.playCount ?? String(songs.value.length)),
|
|
||||||
);
|
|
||||||
const headerCover = computed(() => {
|
const headerCover = computed(() => {
|
||||||
const cover =
|
const cover = summary.value?.cover ?? mockPlaylist.value?.cover ?? songs.value[0]?.cover ?? ""
|
||||||
summary.value?.cover ?? mockPlaylist.value?.cover ?? songs.value[0]?.cover ?? "";
|
return cover ? displayCover(cover) : "linear-gradient(135deg, #4338ca 0%, #7c3aed 100%)"
|
||||||
return cover
|
})
|
||||||
? displayCover(cover)
|
|
||||||
: "linear-gradient(135deg, #4338ca 0%, #7c3aed 100%)";
|
|
||||||
});
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await PlayListApi.getPlayListDetail(Source.KuGou, props.id);
|
const res = await PlayListApi.detail(Source.KuGou, props.id)
|
||||||
songs.value = res.map((song) => ({
|
songs.value = res.map((song) => ({
|
||||||
id: song.id,
|
id: song.id,
|
||||||
title: song.name,
|
title: song.name,
|
||||||
|
|
@ -106,24 +87,35 @@ onMounted(async () => {
|
||||||
album: song.album,
|
album: song.album,
|
||||||
duration: song.duration,
|
duration: song.duration,
|
||||||
cover: displayCover(song.cover),
|
cover: displayCover(song.cover),
|
||||||
}));
|
}))
|
||||||
} catch {
|
} catch {
|
||||||
// 兜底:mock 歌单(如从搜索页进入)继续展示旧数据
|
// 兜底:mock 歌单(如从搜索页进入)继续展示旧数据
|
||||||
const mock = playlists.find((item) => item.id === props.id);
|
const mock = playlists.find((item) => item.id === props.id)
|
||||||
if (mock) songs.value = mock.songs;
|
if (mock) songs.value = mock.songs
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
function playAll() {
|
function playAll() {
|
||||||
if (songs.value.length === 0) return;
|
if (songs.value.length === 0) return
|
||||||
playSong(songs.value[0], songs.value);
|
playWithUrl(songs.value[0], songs.value)
|
||||||
message.success(`正在播放歌单:${headerName.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() {
|
function favorite() {
|
||||||
message.success(`已收藏「${headerName.value}」`);
|
message.success(`已收藏「${headerName.value}」`)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,11 @@
|
||||||
<n-tabs v-model:value="activeTab" type="line" animated>
|
<n-tabs v-model:value="activeTab" type="line" animated>
|
||||||
<n-tab-pane name="song" :tab="`单曲 ${songResults.length}`">
|
<n-tab-pane name="song" :tab="`单曲 ${songResults.length}`">
|
||||||
<div class="result-body">
|
<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="没有找到相关单曲" />
|
<n-empty v-else class="empty" description="没有找到相关单曲" />
|
||||||
</div>
|
</div>
|
||||||
</n-tab-pane>
|
</n-tab-pane>
|
||||||
|
|
@ -65,6 +69,7 @@ import AppIcon from "@/components/AppIcon.vue";
|
||||||
import MusicCard from "@/components/MusicCard.vue";
|
import MusicCard from "@/components/MusicCard.vue";
|
||||||
import SongTable from "@/components/SongTable.vue";
|
import SongTable from "@/components/SongTable.vue";
|
||||||
import { allSongs, hotKeywords, playlists } from "@/mocks/music";
|
import { allSongs, hotKeywords, playlists } from "@/mocks/music";
|
||||||
|
import { playSong } from "@/stores/player";
|
||||||
|
|
||||||
const message = useMessage();
|
const message = useMessage();
|
||||||
const keyword = ref("");
|
const keyword = ref("");
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue