🦄 refactor: 重构整个项目 优化打包 修改后台服务为本地运行 添加更新版本检测功能

This commit is contained in:
alger
2025-01-01 02:25:18 +08:00
parent f8d421c9b1
commit 17d20fa299
260 changed files with 78557 additions and 1693 deletions
+58
View File
@@ -0,0 +1,58 @@
<template>
<div class="app-container" :class="{ mobile: isMobile, noElectron: !isElectron }">
<n-config-provider :theme="theme === 'dark' ? darkTheme : lightTheme">
<n-dialog-provider>
<n-message-provider>
<router-view></router-view>
</n-message-provider>
</n-dialog-provider>
</n-config-provider>
</div>
</template>
<script setup lang="ts">
import { darkTheme, lightTheme } from 'naive-ui';
import { onMounted } from 'vue';
import { isElectron } from '@/utils';
import homeRouter from '@/router/home';
import store from '@/store';
import { isMobile } from './utils';
const theme = computed(() => {
return store.state.theme;
});
onMounted(() => {
store.dispatch('initializeSettings');
store.dispatch('initializeTheme');
if (isMobile.value) {
store.commit(
'setMenus',
homeRouter.filter((item) => item.meta.isMobile)
);
console.log(
'qqq ',
homeRouter.filter((item) => item.meta.isMobile)
);
}
});
</script>
<style lang="scss" scoped>
.app-container {
@apply h-full w-full;
user-select: none;
}
.mobile {
.text-base {
font-size: 14px !important;
}
}
.html:has(.mobile) {
font-size: 14px;
}
</style>
+52
View File
@@ -0,0 +1,52 @@
import { IData } from '@/type';
import { IAlbumNew } from '@/type/album';
import { IDayRecommend } from '@/type/day_recommend';
import { IRecommendMusic } from '@/type/music';
import { IPlayListSort } from '@/type/playlist';
import { IHotSearch, ISearchKeyword } from '@/type/search';
import { IHotSinger } from '@/type/singer';
import request from '@/utils/request';
interface IHotSingerParams {
offset: number;
limit: number;
}
interface IRecommendMusicParams {
limit: number;
}
// 获取热门歌手
export const getHotSinger = (params: IHotSingerParams) => {
return request.get<IHotSinger>('/top/artists', { params });
};
// 获取搜索推荐词
export const getSearchKeyword = () => {
return request.get<ISearchKeyword>('/search/default');
};
// 获取热门搜索
export const getHotSearch = () => {
return request.get<IHotSearch>('/search/hot/detail');
};
// 获取歌单分类
export const getPlaylistCategory = () => {
return request.get<IPlayListSort>('/playlist/catlist');
};
// 获取推荐音乐
export const getRecommendMusic = (params: IRecommendMusicParams) => {
return request.get<IRecommendMusic>('/personalized/newsong', { params });
};
// 获取每日推荐
export const getDayRecommend = () => {
return request.get<IData<IData<IDayRecommend>>>('/recommend/songs');
};
// 获取最新专辑推荐
export const getNewAlbum = () => {
return request.get<IAlbumNew>('/album/newest');
};
+42
View File
@@ -0,0 +1,42 @@
import { IList } from '@/type/list';
import type { IListDetail } from '@/type/listDetail';
import request from '@/utils/request';
interface IListByTagParams {
tag: string;
before: number;
limit: number;
}
interface IListByCatParams {
cat: string;
offset: number;
limit: number;
}
// 根据tag 获取歌单列表
export function getListByTag(params: IListByTagParams) {
return request.get<IList>('/top/playlist/highquality', { params });
}
// 根据cat 获取歌单列表
export function getListByCat(params: IListByCatParams) {
return request.get('/top/playlist', {
params
});
}
// 获取推荐歌单
export function getRecommendList(limit: number = 30) {
return request.get('/personalized', { params: { limit } });
}
// 获取歌单详情
export function getListDetail(id: number | string) {
return request.get<IListDetail>('/playlist/detail', { params: { id } });
}
// 获取专辑内容
export function getAlbum(id: number | string) {
return request.get('/album', { params: { id } });
}
+46
View File
@@ -0,0 +1,46 @@
import request from '@/utils/request';
// 创建二维码key
// /login/qr/key
export function getQrKey() {
return request.get('/login/qr/key');
}
// 创建二维码
// /login/qr/create
export function createQr(key: any) {
return request.get('/login/qr/create', { params: { key, qrimg: true } });
}
// 获取二维码状态
// /login/qr/check
export function checkQr(key: any) {
return request.get('/login/qr/check', { params: { key } });
}
// 获取登录状态
// /login/status
export function getLoginStatus() {
return request.get('/login/status');
}
// 获取用户信息
// /user/account
export function getUserDetail() {
return request.get('/user/account');
}
// 退出登录
// /logout
export function logout() {
return request.get('/logout');
}
// 手机号登录
// /login/cellphone
export function loginByCellphone(phone: string, password: string) {
return request.post('/login/cellphone', {
phone,
password
});
}
+26
View File
@@ -0,0 +1,26 @@
import { ILyric } from '@/type/lyric';
import { IPlayMusicUrl } from '@/type/music';
import { isElectron } from '@/utils';
import request from '@/utils/request';
import requestMusic from '@/utils/request_music';
// 根据音乐Id获取音乐播放URl
export const getMusicUrl = (id: number) => {
return request.get<IPlayMusicUrl>('/song/url', { params: { id } });
};
// 获取歌曲详情
export const getMusicDetail = (ids: Array<number>) => {
return request.get('/song/detail', { params: { ids: ids.join(',') } });
};
// 根据音乐Id获取音乐歌词
export const getMusicLrc = (id: number) => {
return request.get<ILyric>('/lyric', { params: { id } });
};
export const getParsingMusicUrl = (id: number) => {
if (isElectron) {
return window.api.unblockMusic(id);
}
return requestMusic.get<any>('/music', { params: { id } });
};
+45
View File
@@ -0,0 +1,45 @@
import { IData } from '@/type';
import { IMvUrlData } from '@/type/mv';
import request from '@/utils/request';
interface MvParams {
limit?: number;
offset?: number;
area?: string;
}
// 获取 mv 排行
export const getTopMv = (params: MvParams) => {
return request({
url: '/mv/all',
method: 'get',
params
});
};
// 获取所有mv
export const getAllMv = (params: MvParams) => {
return request({
url: '/mv/all',
method: 'get',
params
});
};
// 获取 mv 数据
export const getMvDetail = (mvid: string) => {
return request.get('/mv/detail', {
params: {
mvid
}
});
};
// 获取 mv 地址
export const getMvUrl = (id: Number) => {
return request.get<IData<IMvUrlData>>('/mv/url', {
params: {
id
}
});
};
+12
View File
@@ -0,0 +1,12 @@
import request from '@/utils/request';
interface IParams {
keywords: string;
type: number;
}
// 搜索内容
export const getSearch = (params: IParams) => {
return request.get<any>('/cloudsearch', {
params
});
};
+17
View File
@@ -0,0 +1,17 @@
import request from '@/utils/request';
// /user/detail
export function getUserDetail(uid: number) {
return request.get('/user/detail', { params: { uid } });
}
// /user/playlist
export function getUserPlaylist(uid: number) {
return request.get('/user/playlist', { params: { uid } });
}
// 播放历史
// /user/record?uid=32953014&type=1
export function getUserRecord(uid: number, type: number = 0) {
return request.get('/user/record', { params: { uid, type } });
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
body {
/* background-color: #000; */
}
.n-popover:has(.music-play) {
border-radius: 1.5rem !important;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+283
View File
@@ -0,0 +1,283 @@
@font-face {
font-family: 'iconfont'; /* Project id 2685283 */
src:
url('iconfont.woff2?t=1703643214551') format('woff2'),
url('iconfont.woff?t=1703643214551') format('woff'),
url('iconfont.ttf?t=1703643214551') format('truetype');
}
.iconfont {
font-family: 'iconfont' !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon-list:before {
content: '\e603';
}
.icon-maxsize:before {
content: '\e692';
}
.icon-close:before {
content: '\e616';
}
.icon-minisize:before {
content: '\e602';
}
.icon-shuaxin:before {
content: '\e627';
}
.icon-icon_error:before {
content: '\e615';
}
.icon-a-3User:before {
content: '\e601';
}
.icon-Chat:before {
content: '\e605';
}
.icon-Category:before {
content: '\e606';
}
.icon-Document:before {
content: '\e607';
}
.icon-Heart:before {
content: '\e608';
}
.icon-Hide:before {
content: '\e609';
}
.icon-Home:before {
content: '\e60a';
}
.icon-a-Image2:before {
content: '\e60b';
}
.icon-Profile:before {
content: '\e60c';
}
.icon-Search:before {
content: '\e60d';
}
.icon-Paper:before {
content: '\e60e';
}
.icon-Play:before {
content: '\e60f';
}
.icon-Setting:before {
content: '\e610';
}
.icon-a-TicketStar:before {
content: '\e611';
}
.icon-a-VolumeOff:before {
content: '\e612';
}
.icon-a-VolumeUp:before {
content: '\e613';
}
.icon-a-VolumeDown:before {
content: '\e614';
}
.icon-stop:before {
content: '\e600';
}
.icon-next:before {
content: '\e6a9';
}
.icon-prev:before {
content: '\e6ac';
}
.icon-play:before {
content: '\e6aa';
}
.icon-xiasanjiaoxing:before {
content: '\e642';
}
.icon-videofill:before {
content: '\e7c7';
}
.icon-favorfill:before {
content: '\e64b';
}
.icon-favor:before {
content: '\e64c';
}
.icon-loading:before {
content: '\e64f';
}
.icon-search:before {
content: '\e65c';
}
.icon-likefill:before {
content: '\e668';
}
.icon-like:before {
content: '\e669';
}
.icon-notificationfill:before {
content: '\e66a';
}
.icon-notification:before {
content: '\e66b';
}
.icon-evaluate:before {
content: '\e672';
}
.icon-homefill:before {
content: '\e6bb';
}
.icon-link:before {
content: '\e6bf';
}
.icon-roundaddfill:before {
content: '\e6d8';
}
.icon-roundadd:before {
content: '\e6d9';
}
.icon-add:before {
content: '\e6da';
}
.icon-appreciatefill:before {
content: '\e6e3';
}
.icon-forwardfill:before {
content: '\e6ea';
}
.icon-voicefill:before {
content: '\e6f0';
}
.icon-wefill:before {
content: '\e6f4';
}
.icon-keyboard:before {
content: '\e71b';
}
.icon-picfill:before {
content: '\e72c';
}
.icon-markfill:before {
content: '\e730';
}
.icon-presentfill:before {
content: '\e732';
}
.icon-peoplefill:before {
content: '\e735';
}
.icon-read:before {
content: '\e742';
}
.icon-backwardfill:before {
content: '\e74d';
}
.icon-playfill:before {
content: '\e74f';
}
.icon-all:before {
content: '\e755';
}
.icon-hotfill:before {
content: '\e757';
}
.icon-recordfill:before {
content: '\e7a4';
}
.icon-full:before {
content: '\e7bc';
}
.icon-favor_fill_light:before {
content: '\e7ec';
}
.icon-round_favor_fill:before {
content: '\e80a';
}
.icon-round_location_fill:before {
content: '\e80b';
}
.icon-round_like_fill:before {
content: '\e80c';
}
.icon-round_people_fill:before {
content: '\e80d';
}
.icon-round_skin_fill:before {
content: '\e80e';
}
.icon-broadcast_fill:before {
content: '\e81d';
}
.icon-card_fill:before {
content: '\e81f';
}
File diff suppressed because one or more lines are too long
+478
View File
@@ -0,0 +1,478 @@
{
"id": "2685283",
"name": "music",
"font_family": "iconfont",
"css_prefix_text": "icon-",
"description": "",
"glyphs": [
{
"icon_id": "1111849",
"name": "list",
"font_class": "list",
"unicode": "e603",
"unicode_decimal": 58883
},
{
"icon_id": "1306794",
"name": "maxsize",
"font_class": "maxsize",
"unicode": "e692",
"unicode_decimal": 59026
},
{
"icon_id": "4437591",
"name": "close",
"font_class": "close",
"unicode": "e616",
"unicode_decimal": 58902
},
{
"icon_id": "5383753",
"name": "minisize",
"font_class": "minisize",
"unicode": "e602",
"unicode_decimal": 58882
},
{
"icon_id": "13075017",
"name": "刷新",
"font_class": "shuaxin",
"unicode": "e627",
"unicode_decimal": 58919
},
{
"icon_id": "24457556",
"name": "icon_error",
"font_class": "icon_error",
"unicode": "e615",
"unicode_decimal": 58901
},
{
"icon_id": "24492642",
"name": "3 User",
"font_class": "a-3User",
"unicode": "e601",
"unicode_decimal": 58881
},
{
"icon_id": "24492643",
"name": "Chat",
"font_class": "Chat",
"unicode": "e605",
"unicode_decimal": 58885
},
{
"icon_id": "24492646",
"name": "Category",
"font_class": "Category",
"unicode": "e606",
"unicode_decimal": 58886
},
{
"icon_id": "24492661",
"name": "Document",
"font_class": "Document",
"unicode": "e607",
"unicode_decimal": 58887
},
{
"icon_id": "24492662",
"name": "Heart",
"font_class": "Heart",
"unicode": "e608",
"unicode_decimal": 58888
},
{
"icon_id": "24492665",
"name": "Hide",
"font_class": "Hide",
"unicode": "e609",
"unicode_decimal": 58889
},
{
"icon_id": "24492667",
"name": "Home",
"font_class": "Home",
"unicode": "e60a",
"unicode_decimal": 58890
},
{
"icon_id": "24492678",
"name": "Image 2",
"font_class": "a-Image2",
"unicode": "e60b",
"unicode_decimal": 58891
},
{
"icon_id": "24492684",
"name": "Profile",
"font_class": "Profile",
"unicode": "e60c",
"unicode_decimal": 58892
},
{
"icon_id": "24492685",
"name": "Search",
"font_class": "Search",
"unicode": "e60d",
"unicode_decimal": 58893
},
{
"icon_id": "24492687",
"name": "Paper",
"font_class": "Paper",
"unicode": "e60e",
"unicode_decimal": 58894
},
{
"icon_id": "24492690",
"name": "Play",
"font_class": "Play",
"unicode": "e60f",
"unicode_decimal": 58895
},
{
"icon_id": "24492698",
"name": "Setting",
"font_class": "Setting",
"unicode": "e610",
"unicode_decimal": 58896
},
{
"icon_id": "24492708",
"name": "Ticket Star",
"font_class": "a-TicketStar",
"unicode": "e611",
"unicode_decimal": 58897
},
{
"icon_id": "24492712",
"name": "Volume Off",
"font_class": "a-VolumeOff",
"unicode": "e612",
"unicode_decimal": 58898
},
{
"icon_id": "24492713",
"name": "Volume Up",
"font_class": "a-VolumeUp",
"unicode": "e613",
"unicode_decimal": 58899
},
{
"icon_id": "24492714",
"name": "Volume Down",
"font_class": "a-VolumeDown",
"unicode": "e614",
"unicode_decimal": 58900
},
{
"icon_id": "18875422",
"name": "暂停 停止 灰色",
"font_class": "stop",
"unicode": "e600",
"unicode_decimal": 58880
},
{
"icon_id": "15262786",
"name": "1_music82",
"font_class": "next",
"unicode": "e6a9",
"unicode_decimal": 59049
},
{
"icon_id": "15262807",
"name": "1_music83",
"font_class": "prev",
"unicode": "e6ac",
"unicode_decimal": 59052
},
{
"icon_id": "15262830",
"name": "1_music81",
"font_class": "play",
"unicode": "e6aa",
"unicode_decimal": 59050
},
{
"icon_id": "15367",
"name": "下三角形",
"font_class": "xiasanjiaoxing",
"unicode": "e642",
"unicode_decimal": 58946
},
{
"icon_id": "1096518",
"name": "video_fill",
"font_class": "videofill",
"unicode": "e7c7",
"unicode_decimal": 59335
},
{
"icon_id": "29930",
"name": "favor_fill",
"font_class": "favorfill",
"unicode": "e64b",
"unicode_decimal": 58955
},
{
"icon_id": "29931",
"name": "favor",
"font_class": "favor",
"unicode": "e64c",
"unicode_decimal": 58956
},
{
"icon_id": "29934",
"name": "loading",
"font_class": "loading",
"unicode": "e64f",
"unicode_decimal": 58959
},
{
"icon_id": "29947",
"name": "search",
"font_class": "search",
"unicode": "e65c",
"unicode_decimal": 58972
},
{
"icon_id": "30417",
"name": "like_fill",
"font_class": "likefill",
"unicode": "e668",
"unicode_decimal": 58984
},
{
"icon_id": "30418",
"name": "like",
"font_class": "like",
"unicode": "e669",
"unicode_decimal": 58985
},
{
"icon_id": "30419",
"name": "notification_fill",
"font_class": "notificationfill",
"unicode": "e66a",
"unicode_decimal": 58986
},
{
"icon_id": "30420",
"name": "notification",
"font_class": "notification",
"unicode": "e66b",
"unicode_decimal": 58987
},
{
"icon_id": "30434",
"name": "evaluate",
"font_class": "evaluate",
"unicode": "e672",
"unicode_decimal": 58994
},
{
"icon_id": "33519",
"name": "home_fill",
"font_class": "homefill",
"unicode": "e6bb",
"unicode_decimal": 59067
},
{
"icon_id": "34922",
"name": "link",
"font_class": "link",
"unicode": "e6bf",
"unicode_decimal": 59071
},
{
"icon_id": "38744",
"name": "round_add_fill",
"font_class": "roundaddfill",
"unicode": "e6d8",
"unicode_decimal": 59096
},
{
"icon_id": "38746",
"name": "round_add",
"font_class": "roundadd",
"unicode": "e6d9",
"unicode_decimal": 59097
},
{
"icon_id": "38747",
"name": "add",
"font_class": "add",
"unicode": "e6da",
"unicode_decimal": 59098
},
{
"icon_id": "43903",
"name": "appreciate_fill",
"font_class": "appreciatefill",
"unicode": "e6e3",
"unicode_decimal": 59107
},
{
"icon_id": "52506",
"name": "forward_fill",
"font_class": "forwardfill",
"unicode": "e6ea",
"unicode_decimal": 59114
},
{
"icon_id": "55448",
"name": "voice_fill",
"font_class": "voicefill",
"unicode": "e6f0",
"unicode_decimal": 59120
},
{
"icon_id": "61146",
"name": "we_fill",
"font_class": "wefill",
"unicode": "e6f4",
"unicode_decimal": 59124
},
{
"icon_id": "90847",
"name": "keyboard",
"font_class": "keyboard",
"unicode": "e71b",
"unicode_decimal": 59163
},
{
"icon_id": "127305",
"name": "pic_fill",
"font_class": "picfill",
"unicode": "e72c",
"unicode_decimal": 59180
},
{
"icon_id": "143738",
"name": "mark_fill",
"font_class": "markfill",
"unicode": "e730",
"unicode_decimal": 59184
},
{
"icon_id": "143740",
"name": "present_fill",
"font_class": "presentfill",
"unicode": "e732",
"unicode_decimal": 59186
},
{
"icon_id": "158873",
"name": "people_fill",
"font_class": "peoplefill",
"unicode": "e735",
"unicode_decimal": 59189
},
{
"icon_id": "176313",
"name": "read",
"font_class": "read",
"unicode": "e742",
"unicode_decimal": 59202
},
{
"icon_id": "212324",
"name": "backward_fill",
"font_class": "backwardfill",
"unicode": "e74d",
"unicode_decimal": 59213
},
{
"icon_id": "212328",
"name": "play_fill",
"font_class": "playfill",
"unicode": "e74f",
"unicode_decimal": 59215
},
{
"icon_id": "240126",
"name": "all",
"font_class": "all",
"unicode": "e755",
"unicode_decimal": 59221
},
{
"icon_id": "240128",
"name": "hot_fill",
"font_class": "hotfill",
"unicode": "e757",
"unicode_decimal": 59223
},
{
"icon_id": "747747",
"name": "record_fill",
"font_class": "recordfill",
"unicode": "e7a4",
"unicode_decimal": 59300
},
{
"icon_id": "1005712",
"name": "full",
"font_class": "full",
"unicode": "e7bc",
"unicode_decimal": 59324
},
{
"icon_id": "1512759",
"name": "favor_fill_light",
"font_class": "favor_fill_light",
"unicode": "e7ec",
"unicode_decimal": 59372
},
{
"icon_id": "4110741",
"name": "round_favor_fill",
"font_class": "round_favor_fill",
"unicode": "e80a",
"unicode_decimal": 59402
},
{
"icon_id": "4110743",
"name": "round_location_fill",
"font_class": "round_location_fill",
"unicode": "e80b",
"unicode_decimal": 59403
},
{
"icon_id": "4110745",
"name": "round_like_fill",
"font_class": "round_like_fill",
"unicode": "e80c",
"unicode_decimal": 59404
},
{
"icon_id": "4110746",
"name": "round_people_fill",
"font_class": "round_people_fill",
"unicode": "e80d",
"unicode_decimal": 59405
},
{
"icon_id": "4110750",
"name": "round_skin_fill",
"font_class": "round_skin_fill",
"unicode": "e80e",
"unicode_decimal": 59406
},
{
"icon_id": "11778953",
"name": "broadcast_fill",
"font_class": "broadcast_fill",
"unicode": "e81d",
"unicode_decimal": 59421
},
{
"icon_id": "12625085",
"name": "card_fill",
"font_class": "card_fill",
"unicode": "e81f",
"unicode_decimal": 59423
}
]
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

+75
View File
@@ -0,0 +1,75 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue')['EffectScope']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const effectScope: typeof import('vue')['effectScope']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useDialog: typeof import('naive-ui')['useDialog']
const useId: typeof import('vue')['useId']
const useLoadingBar: typeof import('naive-ui')['useLoadingBar']
const useMessage: typeof import('naive-ui')['useMessage']
const useModel: typeof import('vue')['useModel']
const useNotification: typeof import('naive-ui')['useNotification']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}
+37
View File
@@ -0,0 +1,37 @@
/* eslint-disable */
// @ts-nocheck
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
NAvatar: typeof import('naive-ui')['NAvatar']
NButton: typeof import('naive-ui')['NButton']
NButtonGroup: typeof import('naive-ui')['NButtonGroup']
NCheckbox: typeof import('naive-ui')['NCheckbox']
NConfigProvider: typeof import('naive-ui')['NConfigProvider']
NDialogProvider: typeof import('naive-ui')['NDialogProvider']
NDrawer: typeof import('naive-ui')['NDrawer']
NDropdown: typeof import('naive-ui')['NDropdown']
NEllipsis: typeof import('naive-ui')['NEllipsis']
NEmpty: typeof import('naive-ui')['NEmpty']
NImage: typeof import('naive-ui')['NImage']
NInput: typeof import('naive-ui')['NInput']
NInputNumber: typeof import('naive-ui')['NInputNumber']
NLayout: typeof import('naive-ui')['NLayout']
NMessageProvider: typeof import('naive-ui')['NMessageProvider']
NModal: typeof import('naive-ui')['NModal']
NPopover: typeof import('naive-ui')['NPopover']
NScrollbar: typeof import('naive-ui')['NScrollbar']
NSlider: typeof import('naive-ui')['NSlider']
NSpin: typeof import('naive-ui')['NSpin']
NSwitch: typeof import('naive-ui')['NSwitch']
NTag: typeof import('naive-ui')['NTag']
NTooltip: typeof import('naive-ui')['NTooltip']
NVirtualList: typeof import('naive-ui')['NVirtualList']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
}
}
+69
View File
@@ -0,0 +1,69 @@
<template>
<div class="relative inline-block">
<n-popover trigger="hover" placement="top" :show-arrow="true" :raw="true" :delay="100">
<template #trigger>
<slot>
<n-button
quaternary
class="inline-flex items-center gap-2 px-4 py-2 transition-all duration-300 hover:-translate-y-0.5"
>
请我喝咖啡
</n-button>
</slot>
</template>
<div class="p-6 rounded-lg shadow-lg bg-light dark:bg-gray-800">
<div class="flex gap-10">
<div class="flex flex-col items-center gap-2">
<n-image
:src="alipayQR"
alt="支付宝收款码"
class="w-32 h-32 rounded-lg cursor-none"
preview-disabled
/>
<span class="text-sm text-gray-700 dark:text-gray-200">支付宝</span>
</div>
<div class="flex flex-col items-center gap-2">
<n-image
:src="wechatQR"
alt="微信收款码"
class="w-32 h-32 rounded-lg cursor-none"
preview-disabled
/>
<span class="text-sm text-gray-700 dark:text-gray-200">微信支付</span>
</div>
</div>
<div class="mt-4">
<p
class="text-sm text-gray-700 dark:text-gray-200 text-center cursor-pointer hover:text-green-500"
@click="copyQQ"
>
QQ群789288579
</p>
</div>
</div>
</n-popover>
</div>
</template>
<script setup>
import { NButton, NImage, NPopover } from 'naive-ui';
const message = useMessage();
const copyQQ = () => {
navigator.clipboard.writeText('789288579');
message.success('已复制到剪贴板');
};
defineProps({
alipayQR: {
type: String,
required: true
},
wechatQR: {
type: String,
required: true
}
});
</script>
+316
View File
@@ -0,0 +1,316 @@
<template>
<n-drawer
:show="show"
:height="isMobile ? '100%' : '80%'"
placement="bottom"
block-scroll
mask-closable
:style="{ backgroundColor: 'transparent' }"
:to="`#layout-main`"
@mask-click="close"
>
<div class="music-page">
<div class="music-header h-12 flex items-center justify-between">
<n-ellipsis :line-clamp="1">
<div class="music-title">
{{ name }}
</div>
</n-ellipsis>
<div class="music-close">
<i class="icon iconfont icon-icon_error" @click="close"></i>
</div>
</div>
<div class="music-content">
<!-- 左侧歌单信息 -->
<div class="music-info">
<div class="music-cover">
<n-image
:src="getImgUrl(cover ? listInfo?.coverImgUrl : displayedSongs[0]?.picUrl, '500y500')"
class="cover-img"
preview-disabled
:class="setAnimationClass('animate__fadeIn')"
object-fit="cover"
/>
</div>
<div v-if="listInfo?.creator" class="creator-info">
<n-avatar round :size="24" :src="getImgUrl(listInfo.creator.avatarUrl, '50y50')" />
<span class="creator-name">{{ listInfo.creator.nickname }}</span>
</div>
<n-scrollbar style="max-height: 200">
<div v-if="listInfo?.description" class="music-desc">
{{ listInfo.description }}
</div>
<play-bottom />
</n-scrollbar>
</div>
<!-- 右侧歌曲列表 -->
<div class="music-list-container">
<div class="music-list">
<n-scrollbar @scroll="handleScroll">
<n-spin :show="loadingList || loading">
<div class="music-list-content">
<div
v-for="(item, index) in displayedSongs"
:key="item.id"
class="double-item"
:class="setAnimationClass('animate__bounceInUp')"
:style="getItemAnimationDelay(index)"
>
<song-item :item="formatDetail(item)" @play="handlePlay" />
</div>
<div v-if="isLoadingMore" class="loading-more">加载更多...</div>
<play-bottom />
</div>
</n-spin>
</n-scrollbar>
</div>
<play-bottom />
</div>
</div>
</div>
</n-drawer>
</template>
<script setup lang="ts">
import { useStore } from 'vuex';
import { getMusicDetail } from '@/api/music';
import SongItem from '@/components/common/SongItem.vue';
import { getImgUrl, isMobile, setAnimationClass, setAnimationDelay } from '@/utils';
import PlayBottom from './common/PlayBottom.vue';
const store = useStore();
const props = withDefaults(
defineProps<{
show: boolean;
name: string;
songList: any[];
loading?: boolean;
listInfo?: {
trackIds: { id: number }[];
[key: string]: any;
};
cover?: boolean;
}>(),
{
loading: false,
cover: true
}
);
const emit = defineEmits(['update:show', 'update:loading']);
const page = ref(0);
const pageSize = 20;
const isLoadingMore = ref(false);
const displayedSongs = ref<any[]>([]);
const loadingList = ref(false);
// 计算总数
const total = computed(() => {
if (props.listInfo?.trackIds) {
return props.listInfo.trackIds.length;
}
return props.songList.length;
});
const formatDetail = computed(() => (detail: any) => {
const song = {
artists: detail.ar,
name: detail.al.name,
id: detail.al.id
};
detail.song = song;
detail.picUrl = detail.al.picUrl;
return detail;
});
const handlePlay = () => {
const tracks = props.songList || [];
store.commit(
'setPlayList',
tracks.map((item) => ({
...item,
picUrl: item.al.picUrl,
song: {
artists: item.ar
}
}))
);
};
const close = () => {
emit('update:show', false);
};
// 优化加载更多歌曲的函数
const loadMoreSongs = async () => {
if (isLoadingMore.value || displayedSongs.value.length >= total.value) return;
isLoadingMore.value = true;
try {
if (props.listInfo?.trackIds) {
// 如果有 trackIds,需要分批请求歌曲详情
const start = page.value * pageSize;
const end = Math.min((page.value + 1) * pageSize, total.value);
const trackIds = props.listInfo.trackIds.slice(start, end).map((item) => item.id);
if (trackIds.length > 0) {
const { data } = await getMusicDetail(trackIds);
displayedSongs.value = [...displayedSongs.value, ...data.songs];
page.value++;
}
} else {
// 如果没有 trackIds,直接使用 songList 分页
const start = page.value * pageSize;
const end = Math.min((page.value + 1) * pageSize, props.songList.length);
const newSongs = props.songList.slice(start, end);
displayedSongs.value = [...displayedSongs.value, ...newSongs];
page.value++;
}
} catch (error) {
console.error('加载歌曲失败:', error);
} finally {
isLoadingMore.value = false;
loadingList.value = false;
}
};
const getItemAnimationDelay = (index: number) => {
const currentPageIndex = index % pageSize;
return setAnimationDelay(currentPageIndex, 20);
};
// 修改滚动处理函数
const handleScroll = (e: Event) => {
const target = e.target as HTMLElement;
if (!target) return;
const { scrollTop, scrollHeight, clientHeight } = target;
if (scrollHeight - scrollTop - clientHeight < 100 && !isLoadingMore.value) {
loadMoreSongs();
}
};
watch(
() => props.show,
(newVal) => {
loadingList.value = newVal;
if (!props.cover) {
loadingList.value = false;
}
}
);
// 监听 songList 变化,重置分页状态
watch(
() => props.songList,
(newSongs) => {
page.value = 0;
displayedSongs.value = newSongs.slice(0, pageSize);
if (newSongs.length > pageSize) {
page.value = 1;
}
loadingList.value = false;
},
{ immediate: true }
);
</script>
<style scoped lang="scss">
.music {
&-title {
@apply text-xl font-bold text-gray-900 dark:text-white;
}
&-page {
@apply px-8 w-full h-full bg-light dark:bg-black bg-opacity-75 dark:bg-opacity-75 rounded-t-2xl;
backdrop-filter: blur(20px);
}
&-close {
@apply cursor-pointer text-gray-900 dark:text-white flex gap-2 items-center;
.icon {
@apply text-3xl;
}
}
&-content {
@apply flex h-[calc(100%-60px)];
}
&-info {
@apply w-[25%] flex-shrink-0 pr-8 flex flex-col;
.music-cover {
@apply w-full aspect-square rounded-2xl overflow-hidden mb-4 min-h-[250px];
.cover-img {
@apply w-full h-full object-cover;
}
}
.creator-info {
@apply flex items-center mb-4;
.creator-name {
@apply ml-2 text-gray-700 dark:text-gray-300;
}
}
.music-desc {
@apply text-sm text-gray-600 dark:text-gray-400 leading-relaxed pr-4;
}
}
&-list {
@apply flex-grow min-h-0;
&-container {
@apply flex-grow min-h-0 flex flex-col relative;
}
&-content {
@apply min-h-[calc(80vh-60px)];
}
:deep(.n-virtual-list__scroll) {
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
}
}
.mobile {
.music-page {
@apply px-4;
}
.music-content {
@apply flex-col;
}
.music-info {
@apply w-full pr-0 mb-2 flex flex-row;
.music-cover {
@apply w-[100px] h-[100px] rounded-lg overflow-hidden mb-4;
}
.music-detail {
@apply flex-1 ml-4;
}
}
}
.loading-more {
@apply text-center py-4 text-gray-500 dark:text-gray-400;
}
.double-item {
@apply mb-2 bg-light-100 bg-opacity-20 dark:bg-dark-100 dark:bg-opacity-20 rounded-3xl;
}
</style>
+695
View File
@@ -0,0 +1,695 @@
<template>
<n-drawer :show="show" height="100%" placement="bottom" :z-index="999999999" :to="`#layout-main`">
<div class="mv-detail">
<div
ref="videoContainerRef"
class="video-container"
:class="{ 'cursor-hidden': !showCursor }"
>
<video
ref="videoRef"
:src="mvUrl"
class="video-player"
@ended="handleEnded"
@timeupdate="handleTimeUpdate"
@loadedmetadata="handleLoadedMetadata"
@play="isPlaying = true"
@pause="isPlaying = false"
@click="togglePlay"
></video>
<div v-if="autoPlayBlocked" class="play-hint" @click="togglePlay">
<n-button quaternary circle size="large">
<template #icon>
<n-icon size="48">
<i class="ri-play-circle-line"></i>
</n-icon>
</template>
</n-button>
</div>
<div class="custom-controls" :class="{ 'controls-hidden': !showControls }">
<div class="progress-bar custom-slider">
<n-slider
v-model:value="progress"
:min="0"
:max="100"
:tooltip="false"
:step="0.1"
@update:value="handleProgressChange"
>
<template #rail>
<div class="progress-rail">
<div class="progress-buffer" :style="{ width: `${bufferedProgress}%` }"></div>
</div>
</template>
</n-slider>
</div>
<div class="controls-main">
<div class="left-controls">
<n-tooltip v-if="!props.noList" placement="top">
<template #trigger>
<n-button quaternary circle @click="handlePrev">
<template #icon>
<n-icon size="24">
<n-spin v-if="prevLoading" size="small" />
<i v-else class="ri-skip-back-line"></i>
</n-icon>
</template>
</n-button>
</template>
上一个
</n-tooltip>
<n-tooltip placement="top">
<template #trigger>
<n-button quaternary circle @click="togglePlay">
<template #icon>
<n-icon size="24">
<n-spin v-if="playLoading" size="small" />
<i v-else :class="isPlaying ? 'ri-pause-line' : 'ri-play-line'"></i>
</n-icon>
</template>
</n-button>
</template>
{{ isPlaying ? '暂停' : '播放' }}
</n-tooltip>
<n-tooltip v-if="!props.noList" placement="top">
<template #trigger>
<n-button quaternary circle @click="handleNext">
<template #icon>
<n-icon size="24">
<n-spin v-if="nextLoading" size="small" />
<i v-else class="ri-skip-forward-line"></i>
</n-icon>
</template>
</n-button>
</template>
下一个
</n-tooltip>
<div class="time-display">
{{ formatTime(currentTime) }} / {{ formatTime(duration) }}
</div>
</div>
<div class="right-controls">
<div v-if="!isMobile" class="volume-control custom-slider">
<n-tooltip placement="top">
<template #trigger>
<n-button quaternary circle @click="toggleMute">
<template #icon>
<n-icon size="24">
<i
:class="volume === 0 ? 'ri-volume-mute-line' : 'ri-volume-up-line'"
></i>
</n-icon>
</template>
</n-button>
</template>
{{ volume === 0 ? '取消静音' : '静音' }}
</n-tooltip>
<n-slider
v-model:value="volume"
:min="0"
:max="100"
:tooltip="false"
class="volume-slider"
/>
</div>
<n-tooltip v-if="!props.noList" placement="top">
<template #trigger>
<n-button quaternary circle class="play-mode-btn" @click="togglePlayMode">
<template #icon>
<n-icon size="24">
<i
:class="
playMode === 'single' ? 'ri-repeat-one-line' : 'ri-play-list-line'
"
></i>
</n-icon>
</template>
</n-button>
</template>
{{ playMode === 'single' ? '单曲循环' : '列表循环' }}
</n-tooltip>
<n-tooltip placement="top">
<template #trigger>
<n-button quaternary circle @click="toggleFullscreen">
<template #icon>
<n-icon size="24">
<i
:class="isFullscreen ? 'ri-fullscreen-exit-line' : 'ri-fullscreen-line'"
></i>
</n-icon>
</template>
</n-button>
</template>
{{ isFullscreen ? '退出全屏' : '全屏' }}
</n-tooltip>
<n-tooltip placement="top">
<template #trigger>
<n-button quaternary circle @click="handleClose">
<template #icon>
<n-icon size="24">
<i class="ri-close-line"></i>
</n-icon>
</template>
</n-button>
</template>
关闭
</n-tooltip>
</div>
</div>
</div>
<!-- 添加模式切换提示 -->
<transition name="fade">
<div v-if="showModeHint" class="mode-hint">
<n-icon size="48" class="mode-icon">
<i :class="playMode === 'single' ? 'ri-repeat-one-line' : 'ri-play-list-line'"></i>
</n-icon>
<div class="mode-text">
{{ playMode === 'single' ? '单曲循环' : '自动播放下一个' }}
</div>
</div>
</transition>
</div>
<div class="mv-detail-title" :class="{ 'title-hidden': !showControls }">
<div class="title">
<n-ellipsis>{{ currentMv?.name }}</n-ellipsis>
</div>
</div>
</div>
</n-drawer>
</template>
<script setup lang="ts">
import { NButton, NIcon, NSlider, NTooltip } from 'naive-ui';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useStore } from 'vuex';
import { getMvUrl } from '@/api/mv';
import { IMvItem } from '@/type/mv';
type PlayMode = 'single' | 'auto';
const PLAY_MODE = {
Single: 'single' as PlayMode,
Auto: 'auto' as PlayMode
} as const;
const props = withDefaults(
defineProps<{
show: boolean;
currentMv?: IMvItem;
noList?: boolean;
}>(),
{
show: false,
currentMv: undefined,
noList: false
}
);
const emit = defineEmits<{
(e: 'update:show', value: boolean): void;
(e: 'next', loading: (value: boolean) => void): void;
(e: 'prev', loading: (value: boolean) => void): void;
}>();
const store = useStore();
const mvUrl = ref<string>();
const playMode = ref<PlayMode>(PLAY_MODE.Auto);
const videoRef = ref<HTMLVideoElement>();
const isPlaying = ref(false);
const currentTime = ref(0);
const duration = ref(0);
const progress = ref(0);
const bufferedProgress = ref(0);
const volume = ref(100);
const showControls = ref(true);
let controlsTimer: NodeJS.Timeout | null = null;
const formatTime = (seconds: number) => {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = Math.floor(seconds % 60);
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
};
const togglePlay = () => {
if (!videoRef.value) return;
if (isPlaying.value) {
videoRef.value.pause();
} else {
videoRef.value.play();
}
resetCursorTimer();
};
const toggleMute = () => {
if (!videoRef.value) return;
if (volume.value === 0) {
volume.value = 100;
} else {
volume.value = 0;
}
};
watch(volume, (newVolume) => {
if (videoRef.value) {
videoRef.value.volume = newVolume / 100;
}
});
const handleProgressChange = (value: number) => {
if (!videoRef.value || !duration.value) return;
const newTime = (value / 100) * duration.value;
videoRef.value.currentTime = newTime;
};
const handleTimeUpdate = () => {
if (!videoRef.value) return;
currentTime.value = videoRef.value.currentTime;
if (!isDragging.value) {
progress.value = (currentTime.value / duration.value) * 100;
}
if (videoRef.value.buffered.length > 0) {
bufferedProgress.value = (videoRef.value.buffered.end(0) / duration.value) * 100;
}
};
const handleLoadedMetadata = () => {
if (!videoRef.value) return;
duration.value = videoRef.value.duration;
};
const resetControlsTimer = () => {
if (controlsTimer) {
clearTimeout(controlsTimer);
}
showControls.value = true;
controlsTimer = setTimeout(() => {
if (isPlaying.value) {
showControls.value = false;
}
}, 3000);
};
const handleMouseMove = () => {
resetControlsTimer();
resetCursorTimer();
};
onMounted(() => {
document.addEventListener('mousemove', handleMouseMove);
});
onUnmounted(() => {
document.removeEventListener('mousemove', handleMouseMove);
if (controlsTimer) {
clearTimeout(controlsTimer);
}
if (cursorTimer) {
clearTimeout(cursorTimer);
}
unlockScreenOrientation();
});
// 监听 currentMv 的变化
watch(
() => props.currentMv,
async (newMv) => {
if (newMv) {
await loadMvUrl(newMv);
}
}
);
const autoPlayBlocked = ref(false);
const playLoading = ref(false);
const loadMvUrl = async (mv: IMvItem) => {
playLoading.value = true;
autoPlayBlocked.value = false;
try {
const res = await getMvUrl(mv.id);
mvUrl.value = res.data.data.url;
await nextTick();
if (videoRef.value) {
try {
await videoRef.value.play();
} catch (error) {
console.warn('自动播放失败,可能需要用户交互:', error);
autoPlayBlocked.value = true;
}
}
} catch (error) {
console.error('加载MV地址失败:', error);
} finally {
playLoading.value = false;
}
};
const handleClose = () => {
emit('update:show', false);
if (store.state.playMusicUrl) {
store.commit('setIsPlay', true);
}
};
const handleEnded = () => {
if (playMode.value === PLAY_MODE.Single) {
// 单曲循环模式,重新加载当前MV
if (props.currentMv) {
loadMvUrl(props.currentMv);
}
} else {
// 自动播放模式,触发下一个
emit('next', (value: boolean) => {
nextLoading.value = value;
});
}
};
const togglePlayMode = () => {
playMode.value = playMode.value === PLAY_MODE.Auto ? PLAY_MODE.Single : PLAY_MODE.Auto;
showModeHint.value = true;
setTimeout(() => {
showModeHint.value = false;
}, 1500);
};
const isDragging = ref(false);
// 添加全屏相关的状态和方法
const videoContainerRef = ref<HTMLElement>();
const isFullscreen = ref(false);
// 检查是否支持全屏API
const checkFullscreenAPI = () => {
const doc = document as any;
return {
requestFullscreen:
videoContainerRef.value?.requestFullscreen ||
(videoContainerRef.value as any)?.webkitRequestFullscreen ||
(videoContainerRef.value as any)?.mozRequestFullScreen ||
(videoContainerRef.value as any)?.msRequestFullscreen,
exitFullscreen:
doc.exitFullscreen ||
doc.webkitExitFullscreen ||
doc.mozCancelFullScreen ||
doc.msExitFullscreen,
fullscreenElement:
doc.fullscreenElement ||
doc.webkitFullscreenElement ||
doc.mozFullScreenElement ||
doc.msFullscreenElement,
fullscreenEnabled:
doc.fullscreenEnabled ||
doc.webkitFullscreenEnabled ||
doc.mozFullScreenEnabled ||
doc.msFullscreenEnabled
};
};
// 添加横屏锁定功能
const lockScreenOrientation = async () => {
try {
if ('orientation' in screen) {
await (screen as any).orientation.lock('landscape');
}
} catch (error) {
console.warn('无法锁定屏幕方向:', error);
}
};
const unlockScreenOrientation = () => {
try {
if ('orientation' in screen) {
(screen as any).orientation.unlock();
}
} catch (error) {
console.warn('无法解锁屏幕方向:', error);
}
};
// 修改切换全屏状态的方法
const toggleFullscreen = async () => {
const api = checkFullscreenAPI();
if (!api.fullscreenEnabled) {
console.warn('全屏API不可用');
return;
}
try {
if (!api.fullscreenElement) {
await videoContainerRef.value?.requestFullscreen();
isFullscreen.value = true;
// 在移动端进入全屏时锁定横屏
if (window.innerWidth <= 768) {
await lockScreenOrientation();
}
} else {
await document.exitFullscreen();
isFullscreen.value = false;
// 退出全屏时解锁屏幕方向
if (window.innerWidth <= 768) {
unlockScreenOrientation();
}
}
} catch (error) {
console.error('切换全屏失败:', error);
}
};
// 监听全屏状态变化
const handleFullscreenChange = () => {
const api = checkFullscreenAPI();
isFullscreen.value = !!api.fullscreenElement;
};
// 在组件挂载时添加全屏变化监听
onMounted(() => {
document.addEventListener('fullscreenchange', handleFullscreenChange);
document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
document.addEventListener('mozfullscreenchange', handleFullscreenChange);
document.addEventListener('MSFullscreenChange', handleFullscreenChange);
});
// 在组件卸载时移除监听
onUnmounted(() => {
document.removeEventListener('fullscreenchange', handleFullscreenChange);
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange);
document.removeEventListener('mozfullscreenchange', handleFullscreenChange);
document.removeEventListener('MSFullscreenChange', handleFullscreenChange);
});
// 添加键盘快捷键支持
const handleKeyPress = (e: KeyboardEvent) => {
if (e.key === 'f' || e.key === 'F') {
toggleFullscreen();
}
};
onMounted(() => {
// 添加到现有的 onMounted 中
document.addEventListener('keydown', handleKeyPress);
});
onUnmounted(() => {
// 添加到现有的 onUnmounted 中
document.removeEventListener('keydown', handleKeyPress);
});
// 添加提示状态
const showModeHint = ref(false);
// 添加加载状态
const prevLoading = ref(false);
const nextLoading = ref(false);
// 添加处理函数
const handlePrev = () => {
prevLoading.value = true;
emit('prev', (value: boolean) => {
prevLoading.value = value;
});
};
const handleNext = () => {
nextLoading.value = true;
emit('next', (value: boolean) => {
nextLoading.value = value;
});
};
// 添加鼠标显示状态
const showCursor = ref(true);
let cursorTimer: NodeJS.Timeout | null = null;
// 添加重置鼠标计时器的函数
const resetCursorTimer = () => {
if (cursorTimer) {
clearTimeout(cursorTimer);
}
showCursor.value = true;
if (isPlaying.value && !showControls.value) {
cursorTimer = setTimeout(() => {
showCursor.value = false;
}, 3000);
}
};
// 监听播放状态变化
watch(isPlaying, (newValue) => {
if (!newValue) {
showCursor.value = true;
if (cursorTimer) {
clearTimeout(cursorTimer);
}
} else {
resetCursorTimer();
}
});
// 添加控制栏状态监听
watch(showControls, (newValue) => {
if (newValue) {
showCursor.value = true;
if (cursorTimer) {
clearTimeout(cursorTimer);
}
} else {
resetCursorTimer();
}
});
const isMobile = computed(() => store.state.isMobile);
</script>
<style scoped lang="scss">
.mv-detail {
@apply h-full bg-light dark:bg-black;
&-title {
@apply fixed top-0 left-0 right-0 p-4 z-10 transition-opacity duration-300;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.7), transparent);
.title {
@apply text-white text-lg font-bold;
}
}
}
.video-container {
@apply h-full w-full relative;
.video-player {
@apply h-full w-full object-contain bg-black;
}
.play-hint {
@apply absolute inset-0 flex items-center justify-center bg-black bg-opacity-50;
.n-button {
@apply text-white hover:text-green-500 transition-colors;
}
}
.custom-controls {
@apply absolute bottom-0 left-0 right-0 p-4 transition-opacity duration-300;
background: linear-gradient(to top, rgba(0, 0, 0, 0.7), transparent);
.controls-main {
@apply flex justify-between items-center;
.left-controls,
.right-controls {
@apply flex items-center gap-2;
.n-button {
@apply text-white hover:text-green-500 transition-colors;
}
.time-display {
@apply text-white text-sm ml-4;
}
}
}
}
}
.mode-hint {
@apply absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 flex flex-col items-center;
.mode-icon {
@apply text-white mb-2;
}
.mode-text {
@apply text-white text-sm;
}
}
.custom-slider {
:deep(.n-slider) {
--n-rail-height: 4px;
--n-rail-color: rgba(255, 255, 255, 0.2);
--n-fill-color: #10b981;
--n-handle-size: 12px;
--n-handle-color: #10b981;
}
}
.progress-bar {
@apply mb-4;
.progress-rail {
@apply relative w-full h-1 bg-gray-600;
.progress-buffer {
@apply absolute top-0 left-0 h-full bg-gray-400;
}
}
}
.volume-control {
@apply flex items-center gap-2;
.volume-slider {
width: 100px;
}
}
.controls-hidden {
opacity: 0;
pointer-events: none;
}
.cursor-hidden {
cursor: none;
}
.title-hidden {
opacity: 0;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
+159
View File
@@ -0,0 +1,159 @@
<template>
<!-- 歌单分类列表 -->
<div class="play-list-type">
<div class="title" :class="setAnimationClass('animate__fadeInLeft')">歌单分类</div>
<div>
<template v-for="(item, index) in playlistCategory?.sub" :key="item.name">
<span
v-show="isShowAllPlaylistCategory || index <= 19 || isHiding"
class="play-list-type-item"
:class="
setAnimationClass(
index <= 19
? 'animate__bounceIn'
: !isShowAllPlaylistCategory
? 'animate__backOutLeft'
: 'animate__bounceIn'
) +
' ' +
'type-item-' +
index
"
:style="getAnimationDelay(index)"
@click="handleClickPlaylistType(item.name)"
>{{ item.name }}</span
>
</template>
<div
class="play-list-type-showall"
:class="setAnimationClass('animate__bounceIn')"
:style="
setAnimationDelay(
!isShowAllPlaylistCategory ? 25 : playlistCategory?.sub.length || 100 + 30
)
"
@click="handleToggleShowAllPlaylistCategory"
>
{{ !isShowAllPlaylistCategory ? '显示全部' : '隐藏一些' }}
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { getPlaylistCategory } from '@/api/home';
import type { IPlayListSort } from '@/type/playlist';
import { setAnimationClass, setAnimationDelay } from '@/utils';
// 歌单分类
const playlistCategory = ref<IPlayListSort>();
// 是否显示全部歌单分类
const isShowAllPlaylistCategory = ref<boolean>(false);
const DELAY_TIME = 40;
const getAnimationDelay = computed(() => {
return (index: number) => {
if (index <= 19) {
return setAnimationDelay(index, DELAY_TIME);
}
if (!isShowAllPlaylistCategory.value) {
const nowIndex = (playlistCategory.value?.sub.length || 0) - index;
return setAnimationDelay(nowIndex, DELAY_TIME);
}
return setAnimationDelay(index - 19, DELAY_TIME);
};
});
watch(isShowAllPlaylistCategory, (newVal) => {
if (!newVal) {
const elements = playlistCategory.value?.sub.map((_, index) =>
document.querySelector(`.type-item-${index}`)
) as HTMLElement[];
elements
.slice(20)
.reverse()
.forEach((element, index) => {
if (element) {
setTimeout(
() => {
(element as HTMLElement).style.position = 'absolute';
},
index * DELAY_TIME + 400
);
}
});
setTimeout(
() => {
isHiding.value = false;
document.querySelectorAll('.play-list-type-item').forEach((element) => {
if (element) {
console.log('element', element);
(element as HTMLElement).style.position = 'none';
}
});
},
(playlistCategory.value?.sub.length || 0 - 19) * DELAY_TIME
);
} else {
document.querySelectorAll('.play-list-type-item').forEach((element) => {
if (element) {
(element as HTMLElement).style.position = 'none';
}
});
}
});
// 加载歌单分类
const loadPlaylistCategory = async () => {
const { data } = await getPlaylistCategory();
playlistCategory.value = data;
};
const router = useRouter();
const handleClickPlaylistType = (type: string) => {
router.push({
path: '/list',
query: {
type
}
});
};
const isHiding = ref<boolean>(false);
const handleToggleShowAllPlaylistCategory = () => {
isShowAllPlaylistCategory.value = !isShowAllPlaylistCategory.value;
if (!isShowAllPlaylistCategory.value) {
isHiding.value = true;
}
};
// 页面初始化
onMounted(() => {
loadPlaylistCategory();
});
</script>
<style lang="scss" scoped>
.title {
@apply text-lg font-bold mb-4 text-gray-900 dark:text-white;
}
.play-list-type {
width: 250px;
@apply mr-4;
&-item,
&-showall {
@apply bg-light dark:bg-black text-gray-900 dark:text-white;
@apply py-2 px-3 mr-3 mb-3 inline-block border border-gray-200 dark:border-gray-700 rounded-xl cursor-pointer hover:bg-green-600 hover:text-white transition;
}
&-showall {
@apply block text-center;
}
}
.mobile {
.play-list-type {
@apply mx-0 w-full;
}
}
</style>
+107
View File
@@ -0,0 +1,107 @@
<template>
<div class="recommend-album">
<div class="title" :class="setAnimationClass('animate__fadeInRight')">最新专辑</div>
<div class="recommend-album-list">
<template v-for="(item, index) in albumData?.albums" :key="item.id">
<div
v-if="index < 6"
class="recommend-album-list-item"
:class="setAnimationClass('animate__backInUp')"
:style="setAnimationDelay(index, 100)"
@click="handleClick(item)"
>
<n-image
class="recommend-album-list-item-img"
:src="getImgUrl(item.blurPicUrl, '200y200')"
lazy
preview-disabled
/>
<div class="recommend-album-list-item-content">{{ item.name }}</div>
</div>
</template>
</div>
<MusicList
v-model:show="showMusic"
:name="albumName"
:song-list="songList"
:cover="false"
:loading="loadingList"
:list-info="albumInfo"
/>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { getNewAlbum } from '@/api/home';
import { getAlbum } from '@/api/list';
import type { IAlbumNew } from '@/type/album';
import { getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils';
import MusicList from '@/components/MusicList.vue';
const albumData = ref<IAlbumNew>();
const loadAlbumList = async () => {
const { data } = await getNewAlbum();
albumData.value = data;
};
const showMusic = ref(false);
const songList = ref([]);
const albumName = ref('');
const loadingList = ref(false);
const albumInfo = ref<any>({});
const handleClick = async (item: any) => {
songList.value = [];
albumInfo.value = {};
albumName.value = item.name;
loadingList.value = true;
showMusic.value = true;
const res = await getAlbum(item.id);
songList.value = res.data.songs.map((song: any) => {
song.al.picUrl = song.al.picUrl || item.picUrl;
return song;
});
albumInfo.value = {
...res.data.album,
creator: {
avatarUrl: res.data.album.artist.img1v1Url,
nickname: `${res.data.album.artist.name} - ${res.data.album.company}`
},
description: res.data.album.description
};
loadingList.value = false;
};
onMounted(() => {
loadAlbumList();
});
</script>
<style lang="scss" scoped>
.recommend-album {
@apply flex-1 mx-5;
.title {
@apply text-lg font-bold mb-4 text-gray-900 dark:text-white;
}
.recommend-album-list {
@apply grid grid-cols-2 grid-rows-3 gap-2;
&-item {
@apply rounded-xl overflow-hidden relative;
&-img {
@apply rounded-xl transition w-full h-full;
}
&:hover img {
filter: brightness(50%);
}
&-content {
@apply w-full h-full opacity-0 transition absolute z-10 top-0 left-0 p-4 text-xl text-white bg-opacity-60 bg-black dark:bg-opacity-60 dark:bg-black;
}
&-content:hover {
opacity: 1;
}
}
}
}
</style>
+176
View File
@@ -0,0 +1,176 @@
<template>
<!-- 推荐歌手 -->
<n-scrollbar :size="100" :x-scrollable="true">
<div class="recommend-singer">
<div class="recommend-singer-list">
<div
v-if="dayRecommendData"
class="recommend-singer-item relative"
:class="setAnimationClass('animate__backInRight')"
:style="setAnimationDelay(0, 100)"
>
<div
:style="
setBackgroundImg(getImgUrl(dayRecommendData?.dailySongs[0].al.picUrl, '500y500'))
"
class="recommend-singer-item-bg"
></div>
<div
class="recommend-singer-item-count p-2 text-base text-gray-200 z-10 cursor-pointer"
@click="showMusic = true"
>
<div class="font-bold text-xl">每日推荐</div>
<div class="mt-2">
<p
v-for="item in dayRecommendData?.dailySongs.slice(0, 5)"
:key="item.id"
class="text-el"
>
{{ item.name }}
<br />
</p>
</div>
</div>
</div>
<div
v-for="(item, index) in hotSingerData?.artists"
:key="item.id"
class="recommend-singer-item relative"
:class="setAnimationClass('animate__backInRight')"
:style="setAnimationDelay(index + 1, 100)"
>
<div
:style="setBackgroundImg(getImgUrl(item.picUrl, '500y500'))"
class="recommend-singer-item-bg"
></div>
<div class="recommend-singer-item-count p-2 text-base text-gray-200 z-10">
{{ item.musicSize }}
</div>
<div class="recommend-singer-item-info z-10">
<div class="recommend-singer-item-info-play" @click="toSearchSinger(item.name)">
<i class="iconfont icon-playfill text-xl"></i>
</div>
<div class="ml-4">
<div class="recommend-singer-item-info-name text-el">{{ item.name }}</div>
<div class="recommend-singer-item-info-name text-el">{{ item.name }}</div>
</div>
</div>
</div>
</div>
<music-list
v-if="dayRecommendData?.dailySongs.length"
v-model:show="showMusic"
name="每日推荐列表"
:song-list="dayRecommendData?.dailySongs"
:cover="false"
/>
</div>
</n-scrollbar>
</template>
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { useStore } from 'vuex';
import { getDayRecommend, getHotSinger } from '@/api/home';
import router from '@/router';
import { IDayRecommend } from '@/type/day_recommend';
import type { IHotSinger } from '@/type/singer';
import { getImgUrl, setAnimationClass, setAnimationDelay, setBackgroundImg } from '@/utils';
import MusicList from '@/components/MusicList.vue';
const store = useStore();
// 歌手信息
const hotSingerData = ref<IHotSinger>();
const dayRecommendData = ref<IDayRecommend>();
const showMusic = ref(false);
onMounted(async () => {
await loadData();
});
const loadData = async () => {
try {
// 第一个请求:获取热门歌手
const { data: singerData } = await getHotSinger({ offset: 0, limit: 5 });
// 第二个请求:获取每日推荐
try {
const {
data: { data: dayRecommend }
} = await getDayRecommend();
// 处理数据
if (dayRecommend) {
singerData.artists = singerData.artists.slice(0, 4);
}
dayRecommendData.value = dayRecommend as unknown as IDayRecommend;
} catch (error) {
console.error('error', error);
}
hotSingerData.value = singerData;
} catch (error) {
console.error('error', error);
}
};
const toSearchSinger = (keyword: string) => {
router.push({
path: '/search',
query: {
keyword
}
});
};
// 监听登录状态
watchEffect(() => {
if (store.state.user) {
loadData();
}
});
</script>
<style lang="scss" scoped>
.recommend-singer {
&-list {
@apply flex;
height: 280px;
}
&-item {
@apply flex-1 h-full rounded-3xl p-5 mr-5 flex flex-col justify-between overflow-hidden;
&-bg {
@apply bg-gray-900 dark:bg-gray-800 bg-no-repeat bg-cover bg-center rounded-3xl absolute w-full h-full top-0 left-0 z-0;
filter: brightness(60%);
}
&-info {
@apply flex items-center p-2;
&-play {
@apply w-12 h-12 bg-green-500 rounded-full flex justify-center items-center hover:bg-green-600 cursor-pointer text-white;
}
&-name {
@apply text-gray-100 dark:text-gray-100;
}
}
&-count {
@apply text-gray-100 dark:text-gray-100;
}
}
}
.mobile .recommend-singer {
&-list {
height: 180px;
@apply ml-4;
}
&-item {
@apply p-4 rounded-xl;
&-bg {
@apply rounded-xl;
}
}
}
</style>
@@ -0,0 +1,68 @@
<template>
<div class="recommend-music">
<div class="title" :class="setAnimationClass('animate__fadeInLeft')">本周最热音乐</div>
<div
v-show="recommendMusic?.result"
v-loading="loading"
class="recommend-music-list"
:class="setAnimationClass('animate__bounceInUp')"
>
<!-- 推荐音乐列表 -->
<template v-for="(item, index) in recommendMusic?.result" :key="item.id">
<div
:class="setAnimationClass('animate__bounceInUp')"
:style="setAnimationDelay(index, 100)"
>
<song-item :item="item" @play="handlePlay" />
</div>
</template>
</div>
</div>
</template>
<script lang="ts" setup>
import { useStore } from 'vuex';
import { getRecommendMusic } from '@/api/home';
import type { IRecommendMusic } from '@/type/music';
import { setAnimationClass, setAnimationDelay } from '@/utils';
import SongItem from './common/SongItem.vue';
const store = useStore();
// 推荐歌曲
const recommendMusic = ref<IRecommendMusic>();
const loading = ref(false);
// 加载推荐歌曲
const loadRecommendMusic = async () => {
loading.value = true;
const { data } = await getRecommendMusic({ limit: 10 });
recommendMusic.value = data;
loading.value = false;
};
// 页面初始化
onMounted(() => {
loadRecommendMusic();
});
const handlePlay = () => {
store.commit('setPlayList', recommendMusic.value?.result);
};
</script>
<style lang="scss" scoped>
.title {
@apply text-lg font-bold mb-4 text-gray-900 dark:text-white;
}
.recommend-music {
@apply flex-auto;
.text-ellipsis {
width: 100%;
}
&-list {
@apply rounded-3xl p-2 w-full border border-gray-200 dark:border-gray-700 bg-light dark:bg-black;
}
}
</style>
@@ -0,0 +1,143 @@
<template>
<n-modal
v-model:show="showModal"
preset="dialog"
:show-icon="false"
:mask-closable="true"
class="install-app-modal"
>
<div class="modal-content">
<div class="modal-header">
<div class="app-icon">
<img src="@/assets/logo.png" alt="App Icon" />
</div>
<div class="app-info">
<h2 class="app-name">Alger Music Player {{ config.version }}</h2>
<p class="app-desc mb-2">在桌面安装应用获得更好的体验</p>
<n-checkbox v-model:checked="noPrompt">不再提示</n-checkbox>
</div>
</div>
<div class="modal-actions">
<n-button class="cancel-btn" @click="closeModal">暂不安装</n-button>
<n-button type="primary" class="install-btn" @click="handleInstall">立即安装</n-button>
</div>
<div class="modal-desc mt-4 text-center">
<p class="text-xs text-gray-400">
下载遇到问题
<a
class="text-green-500"
target="_blank"
href="https://github.com/algerkong/AlgerMusicPlayer/releases"
>GitHub</a
>
下载最新版本
</p>
</div>
</div>
</n-modal>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { isElectron, isMobile } from '@/utils';
import config from '../../../../package.json';
const showModal = ref(false);
const noPrompt = ref(false);
const closeModal = () => {
showModal.value = false;
if (noPrompt.value) {
localStorage.setItem('installPromptDismissed', 'true');
}
};
onMounted(() => {
// 如果是 electron 环境,不显示安装提示
if (isElectron || isMobile.value) {
return;
}
// 检查是否已经点击过"暂不安装"
const isDismissed = localStorage.getItem('installPromptDismissed') === 'true';
if (isDismissed) {
return;
}
showModal.value = true;
});
const handleInstall = async (): Promise<void> => {
const { userAgent } = navigator;
console.log('userAgent', userAgent);
const isMac: boolean = userAgent.includes('Mac');
const isWindows: boolean = userAgent.includes('Win');
const isARM: boolean =
userAgent.includes('ARM') || userAgent.includes('arm') || userAgent.includes('OS X');
const isX64: boolean =
userAgent.includes('x86_64') || userAgent.includes('Win64') || userAgent.includes('WOW64');
const isX86: boolean =
!isX64 &&
(userAgent.includes('i686') || userAgent.includes('i386') || userAgent.includes('Win32'));
const getDownloadUrl = (os: string, arch: string): string => {
const version = config.version as string;
const setup = os !== 'mac' ? 'Setup_' : '';
return `https://gh.llkk.cc/https://github.com/algerkong/AlgerMusicPlayer/releases/download/${version}/AlgerMusic_${version}_${setup}${arch}.${os === 'mac' ? 'dmg' : 'exe'}`;
};
const osType: string | null = isMac ? 'mac' : isWindows ? 'windows' : null;
const archType: string | null = isARM ? 'arm64' : isX64 ? 'x64' : isX86 ? 'x86' : null;
const downloadUrl: string | null = osType && archType ? getDownloadUrl(osType, archType) : null;
window.open(downloadUrl || 'https://github.com/algerkong/AlgerMusicPlayer/releases', '_blank');
};
</script>
<style lang="scss" scoped>
.install-app-modal {
:deep(.n-modal) {
@apply max-w-sm;
}
.modal-content {
@apply p-4 pb-0;
.modal-header {
@apply flex items-center mb-6;
.app-icon {
@apply w-20 h-20 mr-4 rounded-2xl overflow-hidden;
img {
@apply w-full h-full object-cover;
}
}
.app-info {
@apply flex-1;
.app-name {
@apply text-xl font-bold mb-1;
}
.app-desc {
@apply text-sm text-gray-400;
}
}
}
.modal-actions {
@apply flex gap-3 mt-4;
.n-button {
@apply flex-1;
}
.cancel-btn {
@apply bg-gray-800 text-gray-300 border-none;
&:hover {
@apply bg-gray-700;
}
}
.install-btn {
@apply bg-green-600 border-none;
&:hover {
@apply bg-green-500;
}
}
}
}
}
</style>
+40
View File
@@ -0,0 +1,40 @@
<script lang="ts" setup>
import { setAnimationClass } from '@/utils';
const props = defineProps({
showPop: {
type: Boolean,
default: false
},
showClose: {
type: Boolean,
default: true
}
});
const musicFullClass = computed(() => {
if (props.showPop) {
return setAnimationClass('animate__fadeInUp');
}
return setAnimationClass('animate__fadeOutDown');
});
</script>
<template>
<div v-show="props.showPop" class="pop-page" :class="musicFullClass">
<i v-if="props.showClose" class="iconfont icon-icon_error close"></i>
<img src="http://code.myalger.top/2000*2000.jpg,f054f0,0f2255" />
<slot></slot>
</div>
</template>
<style lang="scss" scoped>
.pop-page {
height: 800px;
@apply absolute top-4 left-0 w-full;
background-color: #000000f0;
.close {
@apply absolute top-4 right-4 cursor-pointer text-white text-3xl;
}
}
</style>
@@ -0,0 +1,22 @@
<template>
<div v-if="isPlay" class="bottom" :style="{ height }"></div>
</template>
<script setup lang="ts">
import { useStore } from 'vuex';
const store = useStore();
const isPlay = computed(() => store.state.isPlay as boolean);
defineProps({
height: {
type: String,
default: undefined
}
});
</script>
<style lang="scss" scoped>
.bottom {
@apply h-28;
}
</style>
@@ -0,0 +1,140 @@
<template>
<div class="search-item" :class="item.type" @click="handleClick">
<div class="search-item-img">
<n-image
:src="getImgUrl(item.picUrl, item.type === 'mv' ? '320y180' : '100y100')"
lazy
preview-disabled
/>
<div v-if="item.type === 'mv'" class="play">
<i class="iconfont icon icon-play"></i>
</div>
</div>
<div class="search-item-info">
<p class="search-item-name">{{ item.name }}</p>
<p class="search-item-artist">{{ item.desc }}</p>
</div>
<MusicList
v-if="['专辑', 'playlist'].includes(item.type)"
v-model:show="showPop"
:name="item.name"
:song-list="songList"
:list-info="listInfo"
:cover="false"
/>
<mv-player
v-if="item.type === 'mv'"
v-model:show="showPop"
:current-mv="getCurrentMv()"
no-list
/>
</div>
</template>
<script setup lang="ts">
import { useStore } from 'vuex';
import { getAlbum, getListDetail } from '@/api/list';
import MvPlayer from '@/components/MvPlayer.vue';
import { audioService } from '@/services/audioService';
import { IMvItem } from '@/type/mv';
import { getImgUrl } from '@/utils';
import MusicList from '../MusicList.vue';
const props = defineProps<{
item: {
picUrl: string;
name: string;
desc: string;
type: string;
[key: string]: any;
};
}>();
const songList = ref<any[]>([]);
const showPop = ref(false);
const listInfo = ref<any>(null);
const getCurrentMv = () => {
return {
id: props.item.id,
name: props.item.name
} as unknown as IMvItem;
};
const store = useStore();
const handleClick = async () => {
listInfo.value = null;
if (props.item.type === '专辑') {
showPop.value = true;
const res = await getAlbum(props.item.id);
songList.value = res.data.songs.map((song: any) => {
song.al.picUrl = song.al.picUrl || props.item.picUrl;
return song;
});
listInfo.value = {
...res.data.album,
creator: {
avatarUrl: res.data.album.artist.img1v1Url,
nickname: `${res.data.album.artist.name} - ${res.data.album.company}`
},
description: res.data.album.description
};
}
if (props.item.type === 'playlist') {
showPop.value = true;
const res = await getListDetail(props.item.id);
songList.value = res.data.playlist.tracks;
listInfo.value = res.data.playlist;
}
if (props.item.type === 'mv') {
store.commit('setIsPlay', false);
store.commit('setPlayMusic', false);
audioService.getCurrentSound()?.pause();
showPop.value = true;
}
};
</script>
<style scoped lang="scss">
.search-item {
@apply rounded-3xl p-3 flex items-center hover:bg-light-200 dark:hover:bg-gray-800 transition cursor-pointer;
margin: 0 10px;
.search-item-img {
@apply w-12 h-12 mr-4 rounded-2xl overflow-hidden;
}
.search-item-info {
@apply flex-1 overflow-hidden;
&-name {
@apply text-white text-sm text-center;
}
&-artist {
@apply text-gray-400 text-xs text-center;
}
}
}
.mv {
&:hover {
.play {
@apply opacity-60;
}
}
.search-item-img {
width: 160px;
height: 90px;
@apply rounded-lg relative;
}
.play {
@apply absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 opacity-0 transition-opacity;
.icon {
@apply text-white text-5xl;
}
}
}
</style>
+304
View File
@@ -0,0 +1,304 @@
<template>
<div class="song-item" :class="{ 'song-mini': mini, 'song-list': list }">
<n-image
v-if="item.picUrl"
ref="songImg"
:src="getImgUrl(item.picUrl, '100y100')"
class="song-item-img"
preview-disabled
:img-props="{
crossorigin: 'anonymous'
}"
@load="imageLoad"
/>
<div class="song-item-content">
<div v-if="list" class="song-item-content-wrapper">
<n-ellipsis class="song-item-content-title text-ellipsis" line-clamp="1">{{
item.name
}}</n-ellipsis>
<div class="song-item-content-divider">-</div>
<n-ellipsis class="song-item-content-name text-ellipsis" line-clamp="1">
<span v-for="(artists, artistsindex) in item.ar || item.song.artists" :key="artistsindex"
>{{ artists.name
}}{{ artistsindex < (item.ar || item.song.artists).length - 1 ? ' / ' : '' }}</span
>
</n-ellipsis>
</div>
<template v-else>
<div class="song-item-content-title">
<n-ellipsis class="text-ellipsis" line-clamp="1">{{ item.name }}</n-ellipsis>
</div>
<div class="song-item-content-name">
<n-ellipsis class="text-ellipsis" line-clamp="1">
<span
v-for="(artists, artistsindex) in item.ar || item.song.artists"
:key="artistsindex"
>{{ artists.name
}}{{ artistsindex < (item.ar || item.song.artists).length - 1 ? ' / ' : '' }}</span
>
</n-ellipsis>
</div>
</template>
</div>
<div class="song-item-operating" :class="{ 'song-item-operating-list': list }">
<div v-if="favorite" class="song-item-operating-like">
<i
class="iconfont icon-likefill"
:class="{ 'like-active': isFavorite }"
@click.stop="toggleFavorite"
></i>
</div>
<div
class="song-item-operating-play bg-gray-300 dark:bg-gray-800 animate__animated"
:class="{ 'bg-green-600': isPlaying, animate__flipInY: playLoading }"
@click="playMusicEvent(item)"
>
<i v-if="isPlaying && play" class="iconfont icon-stop"></i>
<i v-else class="iconfont icon-playfill"></i>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, useTemplateRef } from 'vue';
import { useStore } from 'vuex';
import { audioService } from '@/services/audioService';
import type { SongResult } from '@/type/music';
import { getImgUrl } from '@/utils';
import { getImageBackground } from '@/utils/linearColor';
const props = withDefaults(
defineProps<{
item: SongResult;
mini?: boolean;
list?: boolean;
favorite?: boolean;
}>(),
{
mini: false,
list: false,
favorite: true
}
);
const store = useStore();
const play = computed(() => store.state.play as boolean);
const playMusic = computed(() => store.state.playMusic);
const playLoading = computed(
() => playMusic.value.id === props.item.id && playMusic.value.playLoading
);
// 判断是否为正在播放的音乐
const isPlaying = computed(() => {
return playMusic.value.id === props.item.id;
});
const emits = defineEmits(['play']);
const songImageRef = useTemplateRef('songImg');
const imageLoad = async () => {
if (!songImageRef.value) {
return;
}
const { backgroundColor } = await getImageBackground(
(songImageRef.value as any).imageRef as unknown as HTMLImageElement
);
// eslint-disable-next-line vue/no-mutating-props
props.item.backgroundColor = backgroundColor;
};
// 播放音乐 设置音乐详情 打开音乐底栏
const playMusicEvent = async (item: SongResult) => {
if (playMusic.value.id === item.id) {
if (play.value) {
store.commit('setPlayMusic', false);
audioService.getCurrentSound()?.pause();
} else {
store.commit('setPlayMusic', true);
audioService.getCurrentSound()?.play();
}
return;
}
await store.commit('setPlay', item);
store.commit('setIsPlay', true);
emits('play', item);
};
// 判断是否已收藏
const isFavorite = computed(() => {
return store.state.favoriteList.includes(props.item.id);
});
// 切换收藏状态
const toggleFavorite = async (e: Event) => {
e.stopPropagation();
if (isFavorite.value) {
store.commit('removeFromFavorite', props.item.id);
} else {
store.commit('addToFavorite', props.item.id);
}
};
</script>
<style lang="scss" scoped>
// 配置文字不可选中
.text-ellipsis {
width: 100%;
}
.song-item {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
@apply rounded-3xl p-3 flex items-center transition bg-transparent dark:text-white text-gray-900;
&:hover {
@apply bg-gray-100 dark:bg-gray-800;
}
&-img {
@apply w-12 h-12 rounded-2xl mr-4;
}
&-content {
@apply flex-1;
&-title {
@apply text-base text-gray-900 dark:text-white;
}
&-name {
@apply text-xs text-gray-500 dark:text-gray-400;
}
}
&-operating {
@apply flex items-center rounded-full ml-4 border dark:border-gray-700 border-gray-200 bg-light dark:bg-black;
.iconfont {
@apply text-xl;
}
.icon-likefill {
@apply text-xl transition text-gray-500 dark:text-gray-400 hover:text-red-500;
}
&-like {
@apply mr-2 cursor-pointer ml-4;
}
.like-active {
@apply text-red-500;
}
&-play {
@apply cursor-pointer rounded-full w-10 h-10 flex justify-center items-center transition
border dark:border-gray-700 border-gray-200 text-gray-900 dark:text-white;
&:hover,
&.bg-green-600 {
@apply bg-green-500 border-green-500 text-white;
}
}
}
}
.song-mini {
@apply p-2 rounded-2xl;
.song-item {
@apply p-0;
&-img {
@apply w-10 h-10 mr-2;
}
&-content {
@apply flex-1;
&-title {
@apply text-sm;
}
&-name {
@apply text-xs;
}
}
&-operating {
@apply pl-2;
.iconfont {
@apply text-base;
}
&-like {
@apply mr-1 ml-1;
}
&-play {
@apply w-8 h-8;
}
}
}
}
.song-list {
@apply p-2 rounded-lg mb-2 border dark:border-gray-800 border-gray-200;
&:hover {
@apply bg-gray-50 dark:bg-gray-800;
}
.song-item-img {
@apply w-10 h-10 rounded-lg mr-3;
}
.song-item-content {
@apply flex items-center flex-1;
&-wrapper {
@apply flex items-center flex-1 text-sm;
}
&-title {
@apply flex-shrink-0 max-w-[45%] text-gray-900 dark:text-white;
}
&-divider {
@apply mx-2 text-gray-500 dark:text-gray-400;
}
&-name {
@apply flex-1 min-w-0 text-gray-500 dark:text-gray-400;
}
}
.song-item-operating {
@apply flex items-center gap-2;
&-like {
@apply cursor-pointer hover:scale-110 transition-transform;
.iconfont {
@apply text-base text-gray-500 dark:text-gray-400 hover:text-red-500;
}
}
&-play {
@apply w-7 h-7 cursor-pointer hover:scale-110 transition-transform;
.iconfont {
@apply text-base;
}
}
}
}
</style>
@@ -0,0 +1,243 @@
<template>
<n-modal
v-model:show="showModal"
preset="dialog"
:show-icon="false"
:mask-closable="true"
class="update-app-modal"
style="width: 800px; max-width: 90vw"
>
<div class="modal-content">
<div class="modal-header">
<div class="app-icon">
<img src="@/assets/logo.png" alt="App Icon" />
</div>
<div class="app-info">
<h2 class="app-name">发现新版本 {{ updateInfo.latestVersion }}</h2>
<p class="app-desc mb-2">当前版本 {{ updateInfo.currentVersion }}</p>
<n-checkbox v-model:checked="noPrompt">不再提示</n-checkbox>
</div>
</div>
<div class="update-info">
<div class="update-title">更新内容</div>
<n-scrollbar style="max-height: 300px">
<div class="update-body" v-html="parsedReleaseNotes"></div>
</n-scrollbar>
</div>
<div class="modal-actions">
<n-button class="cancel-btn" @click="closeModal">暂不更新</n-button>
<n-button type="primary" class="update-btn" @click="handleUpdate">立即更新</n-button>
</div>
<div class="modal-desc mt-4 text-center">
<p class="text-xs text-gray-400">
下载遇到问题
<a
class="text-green-500"
target="_blank"
href="https://github.com/algerkong/AlgerMusicPlayer/releases"
>GitHub</a
>
下载最新版本
</p>
</div>
</div>
</n-modal>
</template>
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue';
import { marked } from 'marked';
import { checkUpdate } from '@/utils';
import config from '../../../../package.json';
// 配置 marked
marked.setOptions({
breaks: true, // 支持 GitHub 风格的换行
gfm: true // 启用 GitHub 风格的 Markdown
});
interface ReleaseInfo {
tag_name: string;
body?: string;
html_url: string;
assets: Array<{
browser_download_url: string;
name: string;
}>;
}
const showModal = ref(false);
const noPrompt = ref(false);
const updateInfo = ref({
hasUpdate: false,
latestVersion: '',
currentVersion: config.version,
releaseInfo: null as ReleaseInfo | null
});
// 解析 Markdown
const parsedReleaseNotes = computed(() => {
if (!updateInfo.value.releaseInfo?.body) return '';
try {
return marked.parse(updateInfo.value.releaseInfo.body);
} catch (error) {
console.error('Error parsing markdown:', error);
return updateInfo.value.releaseInfo.body;
}
});
const closeModal = () => {
showModal.value = false;
if (noPrompt.value) {
localStorage.setItem('updatePromptDismissed', 'true');
}
};
const checkForUpdates = async () => {
try {
const result = await checkUpdate();
updateInfo.value = result;
// 如果有更新且用户没有选择不再提示,则显示弹窗
if (result.hasUpdate && localStorage.getItem('updatePromptDismissed') !== 'true') {
showModal.value = true;
}
} catch (error) {
console.error('检查更新失败:', error);
}
};
const handleUpdate = async () => {
const { userAgent } = navigator;
const isMac: boolean = userAgent.includes('Mac');
const isWindows: boolean = userAgent.includes('Win');
const isARM: boolean =
userAgent.includes('ARM') || userAgent.includes('arm') || userAgent.includes('OS X');
const isX64: boolean =
userAgent.includes('x86_64') || userAgent.includes('Win64') || userAgent.includes('WOW64');
const isX86: boolean =
!isX64 &&
(userAgent.includes('i686') || userAgent.includes('i386') || userAgent.includes('Win32'));
const getDownloadUrl = (os: string, arch: string): string => {
const version = updateInfo.value.latestVersion;
const setup = os !== 'mac' ? 'Setup_' : '';
return `https://gh.llkk.cc/https://github.com/algerkong/AlgerMusicPlayer/releases/download/v${version}/AlgerMusic_${version}_${setup}${arch}.${os === 'mac' ? 'dmg' : 'exe'}`;
};
const osType: string | null = isMac ? 'mac' : isWindows ? 'windows' : null;
const archType: string | null = isARM ? 'arm64' : isX64 ? 'x64' : isX86 ? 'x86' : null;
const downloadUrl: string | null = osType && archType ? getDownloadUrl(osType, archType) : null;
window.open(downloadUrl || 'https://github.com/algerkong/AlgerMusicPlayer/releases/latest', '_blank');
closeModal();
};
onMounted(() => {
checkForUpdates();
});
</script>
<style lang="scss" scoped>
.update-app-modal {
:deep(.n-modal) {
@apply max-w-4xl;
}
.modal-content {
@apply p-6 pb-4;
.modal-header {
@apply flex items-center mb-6;
.app-icon {
@apply w-24 h-24 mr-6 rounded-2xl overflow-hidden;
img {
@apply w-full h-full object-cover;
}
}
.app-info {
@apply flex-1;
.app-name {
@apply text-2xl font-bold mb-2;
}
.app-desc {
@apply text-base text-gray-400;
}
}
}
.update-info {
@apply mb-6 rounded-lg bg-gray-50 dark:bg-gray-800;
.update-title {
@apply text-base font-medium p-4 pb-2;
}
.update-body {
@apply p-4 pt-2 text-gray-600 dark:text-gray-300;
:deep(h1) {
@apply text-xl font-bold mb-3;
}
:deep(h2) {
@apply text-lg font-bold mb-3;
}
:deep(h3) {
@apply text-base font-bold mb-2;
}
:deep(p) {
@apply mb-3 leading-relaxed;
}
:deep(ul) {
@apply list-disc list-inside mb-3;
}
:deep(ol) {
@apply list-decimal list-inside mb-3;
}
:deep(li) {
@apply mb-2 leading-relaxed;
}
:deep(code) {
@apply px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200;
}
:deep(pre) {
@apply p-3 rounded bg-gray-100 dark:bg-gray-700 overflow-x-auto mb-3;
code {
@apply bg-transparent p-0;
}
}
:deep(blockquote) {
@apply pl-4 border-l-4 border-gray-200 dark:border-gray-600 mb-3;
}
:deep(a) {
@apply text-green-500 hover:text-green-600 dark:hover:text-green-400;
}
:deep(hr) {
@apply my-4 border-gray-200 dark:border-gray-600;
}
:deep(table) {
@apply w-full mb-3;
th, td {
@apply px-3 py-2 border border-gray-200 dark:border-gray-600;
}
th {
@apply bg-gray-100 dark:bg-gray-700;
}
}
}
}
.modal-actions {
@apply flex gap-4 mt-6;
.n-button {
@apply flex-1 text-base py-2;
}
.cancel-btn {
@apply bg-gray-800 text-gray-300 border-none;
&:hover {
@apply bg-gray-700;
}
}
.update-btn {
@apply bg-green-600 border-none;
&:hover {
@apply bg-green-500;
}
}
}
}
}
</style>
+65
View File
@@ -0,0 +1,65 @@
export const USER_SET_OPTIONS = [
// {
// label: '打卡',
// key: 'card',
// },
// {
// label: '听歌升级',
// key: 'card_music',
// },
// {
// label: '歌曲次数',
// key: 'listen',
// },
{
label: '退出登录',
key: 'logout'
},
{
label: '设置',
key: 'set'
}
];
export const SEARCH_TYPES = [
{
label: '单曲',
key: 1
},
{
label: '专辑',
key: 10
},
// {
// label: '歌手',
// key: 100,
// },
{
label: '歌单',
key: 1000
},
// {
// label: '用户',
// key: 1002,
// },
{
label: 'MV',
key: 1004
}
// {
// label: '歌词',
// key: 1006,
// },
// {
// label: '电台',
// key: 1009,
// },
// {
// label: '视频',
// key: 1014,
// },
// {
// label: '综合',
// key: 1018,
// },
];
+7
View File
@@ -0,0 +1,7 @@
import { vLoading } from './loading/index';
const directives = {
loading: vLoading
};
export default directives;
+40
View File
@@ -0,0 +1,40 @@
import { createVNode, render, VNode } from 'vue';
import Loading from './index.vue';
const vnode: VNode = createVNode(Loading) as VNode;
export const vLoading = {
// 在绑定元素的父组件 及他自己的所有子节点都挂载完成后调用
mounted: (el: HTMLElement) => {
render(vnode, el);
},
// 在绑定元素的父组件 及他自己的所有子节点都更新后调用
updated: (el: HTMLElement, binding: any) => {
if (binding.value) {
vnode?.component?.exposed?.show();
} else {
vnode?.component?.exposed?.hide();
}
// 动态添加删除自定义class: loading-parent
formatterClass(el, binding);
},
// 绑定元素的父组件卸载后调用
unmounted: () => {
vnode?.component?.exposed?.hide();
}
};
function formatterClass(el: HTMLElement, binding: any) {
const classStr = el.getAttribute('class');
const tagetClass: number = classStr?.indexOf('loading-parent') as number;
if (binding.value) {
if (tagetClass === -1) {
el.setAttribute('class', `${classStr} loading-parent`);
}
} else if (tagetClass > -1) {
const classArray: Array<string> = classStr?.split('') as string[];
classArray.splice(tagetClass - 1, tagetClass + 15);
el.setAttribute('class', classArray?.join(''));
}
}
+93
View File
@@ -0,0 +1,93 @@
<!-- -->
<template>
<div v-if="isShow" class="loading-box">
<div class="mask"></div>
<div class="loading-content-box">
<n-spin size="small" />
<div :style="{ color: textColor }" class="tip">{{ tip }}</div>
</div>
</div>
</template>
<script setup lang="ts">
import { NSpin } from 'naive-ui';
import { ref } from 'vue';
defineProps({
tip: {
type: String,
default() {
return '加载中...';
}
},
maskBackground: {
type: String,
default() {
return 'rgba(0, 0, 0, 0.05)';
}
},
loadingColor: {
type: String,
default() {
return 'rgba(255, 255, 255, 1)';
}
},
textColor: {
type: String,
default() {
return 'rgba(255, 255, 255, 1)';
}
}
});
const isShow = ref(false);
const show = () => {
isShow.value = true;
};
const hide = () => {
isShow.value = false;
};
defineExpose({
show,
hide,
isShow
});
</script>
<style lang="scss" scoped>
.loading-box {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
overflow: hidden;
z-index: 9999;
.n-spin {
// color: #ccc;
}
.mask {
width: 100%;
height: 100%;
@apply bg-light-100 bg-opacity-50 dark:bg-dark-100 dark:bg-opacity-50;
}
.loading-content-box {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.tip {
font-size: 14px;
margin-top: 8px;
}
}
</style>
+187
View File
@@ -0,0 +1,187 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { ref } from 'vue';
// 创建一个使用 IndexedDB 的组合函数
const useIndexedDB = () => {
const db = ref<IDBDatabase | null>(null); // 数据库引用
// 打开数据库并创建表
const initDB = (
dbName: string,
version: number,
stores: { name: string; keyPath?: string }[]
) => {
return new Promise<void>((resolve, reject) => {
const request = indexedDB.open(dbName, version); // 打开数据库请求
request.onupgradeneeded = (event: any) => {
const db = event.target.result; // 获取数据库实例
stores.forEach((store) => {
if (!db.objectStoreNames.contains(store.name)) {
// 确保对象存储(表)创建
db.createObjectStore(store.name, {
keyPath: store.keyPath || 'id',
autoIncrement: true
});
}
});
};
request.onsuccess = (event: any) => {
db.value = event.target.result; // 保存数据库实例
resolve(); // 成功时解析 Promise
};
request.onerror = (event: any) => {
reject(event.target.error); // 失败时拒绝 Promise
};
});
};
// 通用新增数据
const addData = (storeName: string, value: any) => {
return new Promise<void>((resolve, reject) => {
if (!db.value) return reject('数据库未初始化'); // 检查数据库是否已初始化
const tx = db.value.transaction(storeName, 'readwrite'); // 创建事务
const store = tx.objectStore(storeName); // 获取对象存储
const request = store.add(value); // 添加数据请求
request.onsuccess = () => {
console.log('成功'); // 成功时输出
resolve(); // 解析 Promise
};
request.onerror = (event) => {
console.error('新增失败:', (event.target as IDBRequest).error); // 输出错误
reject((event.target as IDBRequest).error); // 拒绝 Promise
};
});
};
// 通用保存数据(新增或更新)
const saveData = (storeName: string, value: any) => {
return new Promise<void>((resolve, reject) => {
if (!db.value) return reject('数据库未初始化');
const tx = db.value.transaction(storeName, 'readwrite');
const store = tx.objectStore(storeName);
const request = store.put(value);
request.onsuccess = () => {
console.log('成功');
resolve();
};
request.onerror = (event) => {
reject((event.target as IDBRequest).error);
};
});
};
// 通用获取数据
const getData = (storeName: string, key: string | number) => {
return new Promise<any>((resolve, reject) => {
if (!db.value) return reject('数据库未初始化');
const tx = db.value.transaction(storeName, 'readonly');
const store = tx.objectStore(storeName);
const request = store.get(key);
request.onsuccess = (event) => {
if (event.target) {
resolve((event.target as IDBRequest).result);
} else {
reject('事件目标为空');
}
};
request.onerror = (event) => {
reject((event.target as IDBRequest).error);
};
});
};
// 删除数据
const deleteData = (storeName: string, key: string | number) => {
return new Promise<void>((resolve, reject) => {
if (!db.value) return reject('数据库未初始化');
const tx = db.value.transaction(storeName, 'readwrite');
const store = tx.objectStore(storeName);
const request = store.delete(key);
request.onsuccess = () => {
console.log('删除成功');
resolve();
};
request.onerror = (event) => {
reject((event.target as IDBRequest).error);
};
});
};
// 查询所有数据
const getAllData = (storeName: string) => {
return new Promise<any[]>((resolve, reject) => {
if (!db.value) return reject('数据库未初始化');
const tx = db.value.transaction(storeName, 'readonly');
const store = tx.objectStore(storeName);
const request = store.getAll();
request.onsuccess = (event) => {
if (event.target) {
resolve((event.target as IDBRequest).result);
} else {
reject('事件目标为空');
}
};
request.onerror = (event) => {
reject((event.target as IDBRequest).error);
};
});
};
// 分页查询数据
const getDataWithPagination = (storeName: string, page: number, pageSize: number) => {
return new Promise<any[]>((resolve, reject) => {
if (!db.value) return reject('数据库未初始化');
const tx = db.value.transaction(storeName, 'readonly');
const store = tx.objectStore(storeName);
const request = store.openCursor(); // 打开游标请求
const results: any[] = []; // 存储结果的数组
let index = 0; // 当前索引
const skip = (page - 1) * pageSize; // 计算跳过的数量
request.onsuccess = (event: any) => {
const cursor = event.target.result; // 获取游标
if (!cursor) {
resolve(results); // 如果没有更多数据,解析结果
return;
}
if (index >= skip && results.length < pageSize) {
results.push(cursor.value); // 添加当前游标值到结果
}
index++; // 增加索引
cursor.continue(); // 继续游标
};
request.onerror = (event: any) => {
reject(event.target.error);
};
});
};
return {
initDB,
addData,
saveData,
getData,
deleteData,
getAllData,
getDataWithPagination
};
};
export default useIndexedDB;
+39
View File
@@ -0,0 +1,39 @@
// musicHistoryHooks
import { useLocalStorage } from '@vueuse/core';
import type { SongResult } from '@/type/music';
export const useMusicHistory = () => {
const musicHistory = useLocalStorage<SongResult[]>('musicHistory', []);
const addMusic = (music: SongResult) => {
const index = musicHistory.value.findIndex((item) => item.id === music.id);
if (index !== -1) {
musicHistory.value[index].count = (musicHistory.value[index].count || 0) + 1;
musicHistory.value.unshift(musicHistory.value.splice(index, 1)[0]);
} else {
musicHistory.value.unshift({ ...music, count: 1 });
}
};
const delMusic = (music: SongResult) => {
const index = musicHistory.value.findIndex((item) => item.id === music.id);
if (index !== -1) {
musicHistory.value.splice(index, 1);
}
};
const musicList = ref(musicHistory.value);
watch(
() => musicHistory.value,
() => {
musicList.value = musicHistory.value;
}
);
return {
musicHistory,
musicList,
addMusic,
delMusic
};
};
+359
View File
@@ -0,0 +1,359 @@
import { computed, ref } from 'vue';
import { audioService } from '@/services/audioService';
import store from '@/store';
import type { ILyricText, SongResult } from '@/type/music';
import { getTextColors } from '@/utils/linearColor';
import { isElectron } from '@/utils';
const windowData = window as any;
export const lrcArray = ref<ILyricText[]>([]); // 歌词数组
export const lrcTimeArray = ref<number[]>([]); // 歌词时间数组
export const nowTime = ref(0); // 当前播放时间
export const allTime = ref(0); // 总播放时间
export const nowIndex = ref(0); // 当前播放歌词
export const correctionTime = ref(0.4); // 歌词矫正时间Correction time
export const currentLrcProgress = ref(0); // 来存储当前歌词的进度
export const playMusic = computed(() => store.state.playMusic as SongResult); // 当前播放歌曲
export const sound = ref<Howl | null>(audioService.getCurrentSound());
export const isLyricWindowOpen = ref(false); // 新增状态
export const textColors = ref(getTextColors());
document.onkeyup = (e) => {
// 检查事件目标是否是输入框元素
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
return;
}
switch (e.code) {
case 'Space':
if (store.state.play) {
store.commit('setPlayMusic', false);
audioService.getCurrentSound()?.pause();
} else {
store.commit('setPlayMusic', true);
audioService.getCurrentSound()?.play();
}
break;
default:
}
};
watch(
() => store.state.playMusicUrl,
(newVal) => {
if (newVal) {
audioService.play(newVal);
sound.value = audioService.getCurrentSound();
audioServiceOn(audioService);
}
}
);
watch(
() => store.state.playMusic,
() => {
nextTick(async () => {
lrcArray.value = playMusic.value.lyric?.lrcArray || [];
lrcTimeArray.value = playMusic.value.lyric?.lrcTimeArray || [];
// 当歌词数据更新时,如果歌词窗口打开,则发送数据
if (isElectron && isLyricWindowOpen.value && lrcArray.value.length > 0) {
sendLyricToWin();
}
});
},
{
deep: true,
immediate: true
}
);
export const audioServiceOn = (audio: typeof audioService) => {
let interval: any = null;
// 监听播放
audio.onPlay(() => {
store.commit('setPlayMusic', true);
interval = setInterval(() => {
nowTime.value = sound.value?.seek() as number;
allTime.value = sound.value?.duration() as number;
const newIndex = getLrcIndex(nowTime.value);
if (newIndex !== nowIndex.value) {
nowIndex.value = newIndex;
currentLrcProgress.value = 0;
// 当歌词索引更新时,发送歌词数据
if (isElectron && isLyricWindowOpen.value) {
sendLyricToWin();
}
}
// 定期发送歌词数据更新
if (isElectron && isLyricWindowOpen.value) {
sendLyricToWin();
}
}, 50);
});
// 监听暂停
audio.onPause(() => {
store.commit('setPlayMusic', false);
clearInterval(interval);
// 暂停时也发送一次状态更新
if (isElectron && isLyricWindowOpen.value) {
sendLyricToWin();
}
});
// 监听结束
audio.onEnd(() => {
if (store.state.playMode === 1) {
// 单曲循环模式
audio.getCurrentSound()?.play();
} else if (store.state.playMode === 2) {
// 随机播放模式
const { playList } = store.state;
if (playList.length <= 1) {
// 如果播放列表只有一首歌或为空,则重新播放当前歌曲
audio.getCurrentSound()?.play();
} else {
// 随机选择一首不同的歌
let randomIndex;
do {
randomIndex = Math.floor(Math.random() * playList.length);
} while (randomIndex === store.state.playListIndex && playList.length > 1);
store.state.playListIndex = randomIndex;
store.commit('setPlay', playList[randomIndex]);
}
} else {
// 列表循环模式
store.commit('nextPlay');
}
});
};
export const play = () => {
audioService.getCurrentSound()?.play();
};
export const pause = () => {
audioService.getCurrentSound()?.pause();
};
const isPlaying = computed(() => store.state.play as boolean);
// 增加矫正时间
export const addCorrectionTime = (time: number) => (correctionTime.value += time);
// 减少矫正时间
export const reduceCorrectionTime = (time: number) => (correctionTime.value -= time);
// 获取当前播放歌词
export const isCurrentLrc = (index: number, time: number): boolean => {
const currentTime = lrcTimeArray.value[index];
const nextTime = lrcTimeArray.value[index + 1];
const nowTime = time + correctionTime.value;
const isTrue = nowTime > currentTime && nowTime < nextTime;
return isTrue;
};
// 获取当前播放歌词INDEX
export const getLrcIndex = (time: number): number => {
for (let i = 0; i < lrcTimeArray.value.length; i++) {
if (isCurrentLrc(i, time)) {
nowIndex.value = i;
return i;
}
}
return nowIndex.value;
};
// 获取当前播放歌词进度
const currentLrcTiming = computed(() => {
const start = lrcTimeArray.value[nowIndex.value] || 0;
const end = lrcTimeArray.value[nowIndex.value + 1] || start + 1;
return { start, end };
});
// 获取歌词样式
export const getLrcStyle = (index: number) => {
if (index === nowIndex.value) {
return {
backgroundImage: `linear-gradient(to right, #ffffff ${currentLrcProgress.value}%, #ffffff8a ${currentLrcProgress.value}%)`,
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
color: 'transparent',
transition: 'background-image 0.1s linear'
};
}
return {};
};
// 播放进度
export const useLyricProgress = () => {
let animationFrameId: number | null = null;
const updateProgress = () => {
if (!isPlaying.value) return;
const currentSound = sound.value;
if (!currentSound) return;
const { start, end } = currentLrcTiming.value;
const duration = end - start;
const elapsed = (currentSound.seek() as number) - start;
currentLrcProgress.value = Math.min(Math.max((elapsed / duration) * 100, 0), 100);
animationFrameId = requestAnimationFrame(updateProgress);
};
const startProgressAnimation = () => {
if (!animationFrameId && isPlaying.value) {
updateProgress();
}
};
const stopProgressAnimation = () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
};
watch(isPlaying, (newIsPlaying) => {
if (newIsPlaying) {
startProgressAnimation();
} else {
stopProgressAnimation();
}
});
onMounted(() => {
if (isPlaying.value) {
startProgressAnimation();
}
});
onUnmounted(() => {
stopProgressAnimation();
});
return {
currentLrcProgress,
getLrcStyle
};
};
// 设置当前播放时间
export const setAudioTime = (index: number) => {
const currentSound = sound.value;
if (!currentSound) return;
currentSound.seek(lrcTimeArray.value[index]);
currentSound.play();
};
// 获取当前播放的歌词
export const getCurrentLrc = () => {
const index = getLrcIndex(nowTime.value);
return {
currentLrc: lrcArray.value[index],
nextLrc: lrcArray.value[index + 1]
};
};
// 获取一句歌词播放时间几秒到几秒
export const getLrcTimeRange = (index: number) => ({
currentTime: lrcTimeArray.value[index],
nextTime: lrcTimeArray.value[index + 1]
});
// 监听歌词数组变化,当切换歌曲时重新初始化歌词窗口
watch(
() => lrcArray.value,
(newLrcArray) => {
if (newLrcArray.length > 0 && isElectron && isLyricWindowOpen.value) {
sendLyricToWin();
}
}
);
// 发送歌词更新数据
export const sendLyricToWin = () => {
if (!isElectron || !isLyricWindowOpen.value) {
console.log('Cannot send lyric: electron or lyric window not available');
return;
}
try {
if (lrcArray.value.length > 0) {
const nowIndex = getLrcIndex(nowTime.value);
const updateData = {
type: 'full',
nowIndex,
nowTime: nowTime.value,
startCurrentTime: lrcTimeArray.value[nowIndex],
nextTime: lrcTimeArray.value[nowIndex + 1],
isPlay: isPlaying.value,
lrcArray: lrcArray.value,
lrcTimeArray: lrcTimeArray.value,
allTime: allTime.value,
playMusic: playMusic.value
};
window.api.sendLyric(JSON.stringify(updateData));
}
} catch (error) {
console.error('Error sending lyric update:', error);
}
};
export const openLyric = () => {
if (!isElectron) return;
console.log('Opening lyric window with current song:', playMusic.value?.name);
isLyricWindowOpen.value = !isLyricWindowOpen.value;
if (isLyricWindowOpen.value) {
setTimeout(() => {
window.api.openLyric();
sendLyricToWin();
}, 500);
sendLyricToWin();
} else {
closeLyric();
}
};
// 添加关闭歌词窗口的方法
export const closeLyric = () => {
if (!isElectron) return;
windowData.electron.ipcRenderer.send('close-lyric');
};
// 添加播放控制命令监听
if (isElectron) {
windowData.electron.ipcRenderer.on('lyric-control-back', (_, command: string) => {
switch (command) {
case 'playpause':
if (store.state.play) {
store.commit('setPlayMusic', false);
audioService.getCurrentSound()?.pause();
} else {
store.commit('setPlayMusic', true);
audioService.getCurrentSound()?.play();
}
break;
case 'prev':
store.commit('prevPlay');
break;
case 'next':
store.commit('nextPlay');
break;
case 'close':
closeLyric();
break;
default:
console.log('Unknown command:', command);
break;
}
});
}
+195
View File
@@ -0,0 +1,195 @@
import { Howl } from 'howler';
import { getMusicLrc, getMusicUrl, getParsingMusicUrl } from '@/api/music';
import { useMusicHistory } from '@/hooks/MusicHistoryHook';
import { audioService } from '@/services/audioService';
import type { ILyric, ILyricText, SongResult } from '@/type/music';
import { getImgUrl, getMusicProxyUrl } from '@/utils';
import { getImageLinearBackground } from '@/utils/linearColor';
const musicHistory = useMusicHistory();
// 获取歌曲url
const getSongUrl = async (id: number) => {
const { data } = await getMusicUrl(id);
let url = '';
try {
if (data.data[0].freeTrialInfo || !data.data[0].url) {
const res = await getParsingMusicUrl(id);
console.log('res', res);
url = res.data.data.url;
}
} catch (error) {
console.error('error', error);
}
url = url || data.data[0].url;
return getMusicProxyUrl(url);
};
const getSongDetail = async (playMusic: SongResult) => {
playMusic.playLoading = true;
const playMusicUrl = await getSongUrl(playMusic.id);
const { backgroundColor, primaryColor } =
playMusic.backgroundColor && playMusic.primaryColor
? playMusic
: await getImageLinearBackground(getImgUrl(playMusic?.picUrl, '30y30'));
playMusic.playLoading = false;
return { ...playMusic, playMusicUrl, backgroundColor, primaryColor };
};
// 加载 当前歌曲 歌曲列表数据 下一首mp3预加载 歌词数据
export const useMusicListHook = () => {
const handlePlayMusic = async (state: any, playMusic: SongResult) => {
const updatedPlayMusic = await getSongDetail(playMusic);
state.playMusic = updatedPlayMusic;
state.playMusicUrl = updatedPlayMusic.playMusicUrl;
state.play = true;
// 设置网页标题
document.title = `${updatedPlayMusic.name} - ${updatedPlayMusic?.song?.artists?.reduce((prev, curr) => `${prev}${curr.name}/`, '')}`;
loadLrcAsync(state, updatedPlayMusic.id);
musicHistory.addMusic(state.playMusic);
const playListIndex = state.playList.findIndex((item: SongResult) => item.id === playMusic.id);
state.playListIndex = playListIndex;
// 请求后续五首歌曲的详情
fetchSongs(state, playListIndex + 1, playListIndex + 6);
};
// 用于预加载下一首歌曲的 MP3 数据
const preloadNextSong = (nextSongUrl: string) => {
const sound = new Howl({
src: [nextSongUrl],
html5: true,
preload: true,
autoplay: false
});
return sound;
};
const fetchSongs = async (state: any, startIndex: number, endIndex: number) => {
const songs = state.playList.slice(
Math.max(0, startIndex),
Math.min(endIndex, state.playList.length)
);
const detailedSongs = await Promise.all(
songs.map(async (song: SongResult) => {
// 如果歌曲详情已经存在,就不重复请求
if (!song.playMusicUrl) {
return await getSongDetail(song);
}
return song;
})
);
// 加载下一首的歌词
const nextSong = detailedSongs[0];
if (!(nextSong.lyric && nextSong.lyric.lrcTimeArray.length > 0)) {
nextSong.lyric = await loadLrc(nextSong.id);
}
// 更新播放列表中的歌曲详情
detailedSongs.forEach((song, index) => {
state.playList[startIndex + index] = song;
});
preloadNextSong(nextSong.playMusicUrl);
};
const nextPlay = async (state: any) => {
if (state.playList.length === 0) {
state.play = true;
return;
}
const playListIndex = (state.playListIndex + 1) % state.playList.length;
await handlePlayMusic(state, state.playList[playListIndex]);
};
const prevPlay = async (state: any) => {
if (state.playList.length === 0) {
state.play = true;
return;
}
const playListIndex = (state.playListIndex - 1 + state.playList.length) % state.playList.length;
await handlePlayMusic(state, state.playList[playListIndex]);
await fetchSongs(state, playListIndex - 5, playListIndex);
};
const parseTime = (timeString: string): number => {
const [minutes, seconds] = timeString.split(':');
return Number(minutes) * 60 + Number(seconds);
};
const parseLyricLine = (lyricLine: string): { time: number; text: string } => {
const TIME_REGEX = /(\d{2}:\d{2}(\.\d*)?)/g;
const LRC_REGEX = /(\[(\d{2}):(\d{2})(\.(\d*))?\])/g;
const timeText = lyricLine.match(TIME_REGEX)?.[0] || '';
const time = parseTime(timeText);
const text = lyricLine.replace(LRC_REGEX, '').trim();
return { time, text };
};
const parseLyrics = (lyricsString: string): { lyrics: ILyricText[]; times: number[] } => {
const lines = lyricsString.split('\n');
const lyrics: ILyricText[] = [];
const times: number[] = [];
lines.forEach((line) => {
const { time, text } = parseLyricLine(line);
times.push(time);
lyrics.push({ text, trText: '' });
});
return { lyrics, times };
};
const loadLrc = async (playMusicId: number): Promise<ILyric> => {
try {
const { data } = await getMusicLrc(playMusicId);
const { lyrics, times } = parseLyrics(data.lrc.lyric);
const tlyric: Record<string, string> = {};
if (data.tlyric.lyric) {
const { lyrics: tLyrics, times: tTimes } = parseLyrics(data.tlyric.lyric);
tLyrics.forEach((lyric, index) => {
tlyric[tTimes[index].toString()] = lyric.text;
});
}
lyrics.forEach((item, index) => {
item.trText = item.text ? tlyric[times[index].toString()] || '' : '';
});
return {
lrcTimeArray: times,
lrcArray: lyrics
};
} catch (err) {
console.error('Error loading lyrics:', err);
return {
lrcTimeArray: [],
lrcArray: []
};
}
};
// 异步加载歌词的方法
const loadLrcAsync = async (state: any, playMusicId: number) => {
if (state.playMusic.lyric && state.playMusic.lyric.lrcTimeArray.length > 0) {
return;
}
const lyrics = await loadLrc(playMusicId);
state.playMusic.lyric = lyrics;
};
const play = () => {
audioService.getCurrentSound()?.play();
};
const pause = () => {
audioService.getCurrentSound()?.pause();
};
return {
handlePlayMusic,
nextPlay,
prevPlay,
play,
pause
};
};
+58
View File
@@ -0,0 +1,58 @@
/* ./src/index.css */
/*! @import */
@tailwind base;
@tailwind components;
@tailwind utilities;
.n-image img {
background-color: #111111;
width: 100%;
}
.n-slider-handle-indicator--top {
@apply bg-transparent dark:text-[#ffffffdd] text-[#000000dd] text-2xl px-2 py-1 shadow-none mb-0 !important;
}
.text-el {
@apply overflow-ellipsis overflow-hidden whitespace-nowrap;
}
.theme-dark {
--bg-color: #000;
--text-color: #fff;
--bg-color-100: #161616;
--bg-color-200: #2d2d2d;
--bg-color-300: #3d3d3d;
--text-color: #f8f9fa;
--text-color-100: #e9ecef;
--text-color-200: #dee2e6;
--text-color-300: #dde0e3;
--primary-color: #22c55e;
}
.theme-light {
--bg-color: #fff;
--text-color: #000;
--bg-color-100: #f8f9fa;
--bg-color-200: #e9ecef;
--bg-color-300: #dee2e6;
--text-color: #000;
--text-color-100: #161616;
--text-color-200: #2d2d2d;
--text-color-300: #3d3d3d;
--primary-color: #22c55e;
}
.theme-gray {
--bg-color: #f8f9fa;
--text-color: #000;
--bg-color-100: #e9ecef;
--bg-color-200: #dee2e6;
--bg-color-300: #dde0e3;
--text-color: #000;
--text-color-100: #161616;
--text-color-200: #2d2d2d;
--text-color-300: #3d3d3d;
--primary-color: #22c55e;
}
+58
View File
@@ -0,0 +1,58 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<!-- SEO 元数据 -->
<title>网抑云音乐 | AlgerKong AlgerMusicPlayer</title>
<meta
name="description"
content="AlgerMusicPlayer 网抑云音乐 基于 网易云音乐API 的一款免费的在线音乐播放器,支持在线播放、歌词显示、音乐下载等功能。提供海量音乐资源,让您随时随地享受音乐。"
/>
<meta
name="keywords"
content="AlgerMusic, AlgerMusicPlayer, 网抑云, 音乐播放器, 在线音乐, 免费音乐, 歌词显示, 音乐下载, AlgerKong, 网易云音乐"
/>
<!-- 作者信息 -->
<meta name="author" content="AlgerKong" />
<meta name="author-url" content="https://github.com/algerkong" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="apple-mobile-web-app-title" content="网抑云音乐" />
<!-- 资源预加载 -->
<link rel="preload" href="./assets/icon/iconfont.css" as="style" />
<link rel="preload" href="./assets/css/animate.css" as="style" />
<link rel="preload" href="./assets/css/base.css" as="style" />
<!-- 样式表 -->
<link rel="stylesheet" href="./assets/icon/iconfont.css" />
<link rel="stylesheet" href="./assets/css/animate.css" />
<link rel="stylesheet" href="./assets/css/base.css" />
<script defer src="https://cn.vercount.one/js"></script>
<!-- 动画配置 -->
<style>
:root {
--animate-delay: 0.5s;
}
</style>
</head>
<body>
<div id="app"></div>
<div style="display: none">
Total Page View <span id="vercount_value_page_pv">Loading</span> Total Visits
<span id="vercount_value_site_pv">Loading</span> Site Total Visitors
<span id="vercount_value_site_uv">Loading</span>
</div>
<script type="module" src="./main.ts"></script>
</body>
</html>
+110
View File
@@ -0,0 +1,110 @@
<template>
<div class="layout-page">
<div id="layout-main" class="layout-main">
<title-bar v-if="isElectron" />
<div class="layout-main-page" :class="isElectron ? '' : 'pt-6'">
<!-- 侧边菜单栏 -->
<app-menu v-if="!isMobile" class="menu" :menus="menus" />
<div class="main">
<!-- 搜索栏 -->
<search-bar />
<!-- 主页面路由 -->
<div class="main-content" :native-scrollbar="false">
<router-view
v-slot="{ Component }"
class="main-page"
:class="route.meta.noScroll && !isMobile ? 'pr-3' : ''"
>
<keep-alive :include="keepAliveInclude">
<component :is="Component" />
</keep-alive>
</router-view>
</div>
<play-bottom height="5rem" />
<app-menu v-if="isMobile" class="menu" :menus="menus" />
</div>
</div>
<!-- 底部音乐播放 -->
<play-bar v-if="isPlay" />
</div>
<install-app-modal></install-app-modal>
<update-modal />
</div>
</template>
<script lang="ts" setup>
import { computed, defineAsyncComponent, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
import InstallAppModal from '@/components/common/InstallAppModal.vue';
import PlayBottom from '@/components/common/PlayBottom.vue';
import homeRouter from '@/router/home';
import { isElectron, isMobile } from '@/utils';
import UpdateModal from '@/components/common/UpdateModal.vue';
const keepAliveInclude = computed(() =>
homeRouter
.filter((item) => {
return item.meta.keepAlive;
})
.map((item) => {
return item.name.charAt(0).toUpperCase() + item.name.slice(1);
})
);
const AppMenu = defineAsyncComponent(() => import('./components/AppMenu.vue'));
const PlayBar = defineAsyncComponent(() => import('./components/PlayBar.vue'));
const SearchBar = defineAsyncComponent(() => import('./components/SearchBar.vue'));
const TitleBar = defineAsyncComponent(() => import('./components/TitleBar.vue'));
const store = useStore();
const isPlay = computed(() => store.state.isPlay as boolean);
const { menus } = store.state;
const route = useRoute();
onMounted(() => {
store.dispatch('initializeSettings');
store.dispatch('initializeTheme');
});
</script>
<style lang="scss" scoped>
.layout-page {
@apply w-screen h-screen overflow-hidden bg-light dark:bg-black;
}
.layout-main {
@apply w-full h-full relative text-gray-900 dark:text-white;
}
.layout-main-page {
@apply flex h-full;
}
.menu {
@apply h-full;
}
.main {
@apply overflow-hidden flex-1 flex flex-col;
}
.main-content {
@apply flex-1 overflow-hidden;
}
.main-page {
@apply h-full;
}
.mobile {
.main-content {
height: calc(100vh - 146px);
overflow: auto;
display: block;
flex: none;
}
}
</style>
+135
View File
@@ -0,0 +1,135 @@
<template>
<div>
<!-- menu -->
<div class="app-menu" :class="{ 'app-menu-expanded': isText }">
<div class="app-menu-header">
<div class="app-menu-logo" @click="isText = !isText">
<img :src="icon" class="w-9 h-9" alt="logo" />
</div>
</div>
<div class="app-menu-list">
<div v-for="(item, index) in menus" :key="item.path" class="app-menu-item">
<router-link class="app-menu-item-link" :to="item.path">
<i
class="iconfont app-menu-item-icon"
:style="iconStyle(index)"
:class="item.meta.icon"
></i>
<span
v-if="isText"
class="app-menu-item-text ml-3"
:class="isChecked(index) ? 'text-green-500' : ''"
>{{ item.meta.title }}</span
>
</router-link>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { useRoute } from 'vue-router';
import icon from '@/assets/icon.png';
const props = defineProps({
size: {
type: String,
default: '26px'
},
color: {
type: String,
default: '#aaa'
},
selectColor: {
type: String,
default: '#10B981'
},
menus: {
type: Array as any,
default: () => []
}
});
const route = useRoute();
const path = ref(route.path);
watch(
() => route.path,
async (newParams) => {
path.value = newParams;
}
);
const isChecked = (index: number) => {
return path.value === props.menus[index].path;
};
const iconStyle = (index: number) => {
const style = {
fontSize: props.size,
color: isChecked(index) ? props.selectColor : props.color
};
return style;
};
const isText = ref(false);
</script>
<style lang="scss" scoped>
.app-menu {
@apply flex-col items-center justify-center transition-all duration-300 w-[100px] px-1;
}
.app-menu-expanded {
@apply w-[160px];
.app-menu-item {
@apply hover:bg-gray-100 dark:hover:bg-gray-800 rounded mr-4;
}
}
.app-menu-item-link,
.app-menu-header {
@apply flex items-center w-[200px] overflow-hidden ml-2 px-5;
}
.app-menu-header {
@apply ml-1;
}
.app-menu-item-link {
@apply mb-6 mt-6;
}
.app-menu-item-icon {
@apply transition-all duration-200 text-gray-500 dark:text-gray-400;
&:hover {
@apply text-green-500 scale-105 !important;
}
}
.mobile {
.app-menu {
max-width: 100%;
width: 100vw;
position: relative;
z-index: 999999;
@apply bg-light dark:bg-black border-t border-gray-200 dark:border-gray-700;
&-header {
display: none;
}
&-list {
@apply flex justify-between;
}
&-item {
&-link {
@apply my-4 w-auto;
}
}
}
}
</style>
@@ -0,0 +1,330 @@
<template>
<n-drawer
:show="musicFull"
height="100%"
placement="bottom"
:style="{ background: currentBackground || background }"
:to="`#layout-main`"
>
<div id="drawer-target">
<div class="drawer-back"></div>
<div
class="music-img"
:style="{ color: textColors.theme === 'dark' ? '#000000' : '#ffffff' }"
>
<n-image
ref="PicImgRef"
:src="getImgUrl(playMusic?.picUrl, '500y500')"
class="img"
lazy
preview-disabled
/>
<div>
<div class="music-content-name">{{ playMusic.name }}</div>
<div class="music-content-singer">
<span v-for="(item, index) in playMusic.ar || playMusic.song.artists" :key="index">
{{ item.name
}}{{ index < (playMusic.ar || playMusic.song.artists).length - 1 ? ' / ' : '' }}
</span>
</div>
</div>
</div>
<div class="music-content">
<n-layout
ref="lrcSider"
class="music-lrc"
style="height: 60vh"
:native-scrollbar="false"
@mouseover="mouseOverLayout"
@mouseleave="mouseLeaveLayout"
>
<div ref="lrcContainer">
<div
v-for="(item, index) in lrcArray"
:id="`music-lrc-text-${index}`"
:key="index"
class="music-lrc-text"
:class="{ 'now-text': index === nowIndex, 'hover-text': item.text }"
@click="setAudioTime(index)"
>
<span :style="getLrcStyle(index)">{{ item.text }}</span>
<div class="music-lrc-text-tr">{{ item.trText }}</div>
</div>
<!-- 无歌词 -->
<div v-if="!lrcArray.length" class="music-lrc-text mt-40">
<span>暂无歌词, 请欣赏</span>
</div>
</div>
</n-layout>
<!-- 时间矫正 -->
<!-- <div class="music-content-time">
<n-button @click="reduceCorrectionTime(0.2)">-</n-button>
<n-button @click="addCorrectionTime(0.2)">+</n-button>
</div> -->
</div>
</div>
</n-drawer>
</template>
<script setup lang="ts">
import { useDebounceFn } from '@vueuse/core';
import { onBeforeUnmount, ref, watch } from 'vue';
import {
lrcArray,
nowIndex,
playMusic,
setAudioTime,
textColors,
useLyricProgress
} from '@/hooks/MusicHook';
import { getImgUrl } from '@/utils';
import { animateGradient, getHoverBackgroundColor, getTextColors } from '@/utils/linearColor';
// 定义 refs
const lrcSider = ref<any>(null);
const isMouse = ref(false);
const lrcContainer = ref<HTMLElement | null>(null);
const currentBackground = ref('');
const animationFrame = ref<number | null>(null);
const isDark = ref(false);
const props = defineProps({
musicFull: {
type: Boolean,
default: false
},
background: {
type: String,
default: ''
}
});
// 歌词滚动方法
const lrcScroll = (behavior = 'smooth') => {
const nowEl = document.querySelector(`#music-lrc-text-${nowIndex.value}`);
if (props.musicFull && !isMouse.value && nowEl && lrcContainer.value) {
const containerRect = lrcContainer.value.getBoundingClientRect();
const nowElRect = nowEl.getBoundingClientRect();
const relativeTop = nowElRect.top - containerRect.top;
const scrollTop = relativeTop - lrcSider.value.$el.getBoundingClientRect().height / 2;
lrcSider.value.scrollTo({ top: scrollTop, behavior });
}
};
const debouncedLrcScroll = useDebounceFn(lrcScroll, 200);
const mouseOverLayout = () => {
isMouse.value = true;
};
const mouseLeaveLayout = () => {
setTimeout(() => {
isMouse.value = false;
lrcScroll();
}, 2000);
};
watch(nowIndex, () => {
debouncedLrcScroll();
});
watch(
() => props.musicFull,
() => {
if (props.musicFull) {
nextTick(() => {
lrcScroll('instant');
});
}
}
);
// 监听背景变化
watch(
() => props.background,
(newBg) => {
if (!newBg) {
textColors.value = getTextColors();
document.documentElement.style.setProperty(
'--hover-bg-color',
getHoverBackgroundColor(false)
);
document.documentElement.style.setProperty('--text-color-primary', textColors.value.primary);
document.documentElement.style.setProperty('--text-color-active', textColors.value.active);
return;
}
if (currentBackground.value) {
if (animationFrame.value) {
cancelAnimationFrame(animationFrame.value);
}
animationFrame.value = animateGradient(currentBackground.value, newBg, (gradient) => {
currentBackground.value = gradient;
});
} else {
currentBackground.value = newBg;
}
textColors.value = getTextColors(newBg);
isDark.value = textColors.value.active === '#000000';
document.documentElement.style.setProperty(
'--hover-bg-color',
getHoverBackgroundColor(isDark.value)
);
document.documentElement.style.setProperty('--text-color-primary', textColors.value.primary);
document.documentElement.style.setProperty('--text-color-active', textColors.value.active);
},
{ immediate: true }
);
// 修改 useLyricProgress 的使用方式
const { getLrcStyle: originalLrcStyle } = useLyricProgress();
// 修改 getLrcStyle 函数
const getLrcStyle = (index: number) => {
const colors = textColors.value || getTextColors;
const originalStyle = originalLrcStyle(index);
if (index === nowIndex.value) {
// 当前播放的歌词,使用渐变效果
return {
...originalStyle,
backgroundImage: originalStyle.backgroundImage
?.replace(/#ffffff/g, colors.active)
.replace(/#ffffff8a/g, `${colors.primary}`),
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
color: 'transparent'
};
}
// 非当前播放的歌词,使用普通颜色
return {
color: colors.primary
};
};
// 组件卸载时清理动画
onBeforeUnmount(() => {
if (animationFrame.value) {
cancelAnimationFrame(animationFrame.value);
}
});
defineExpose({
lrcScroll
});
</script>
<style scoped lang="scss">
@keyframes round {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.drawer-back {
@apply absolute bg-cover bg-center;
z-index: -1;
width: 200%;
height: 200%;
top: -50%;
left: -50%;
}
.drawer-back.paused {
animation-play-state: paused;
}
#drawer-target {
@apply top-0 left-0 absolute overflow-hidden rounded px-24 flex items-center justify-center w-full h-full pb-8;
animation-duration: 300ms;
.music-img {
@apply flex-1 flex justify-center mr-16 flex-col;
max-width: 360px;
max-height: 360px;
.img {
@apply rounded-xl w-full h-full shadow-2xl;
}
}
.music-content {
@apply flex flex-col justify-center items-center relative;
&-name {
@apply font-bold text-xl pb-1 pt-4;
}
&-singer {
@apply text-base;
}
}
.music-content-time {
display: none;
@apply flex justify-center items-center;
}
.music-lrc {
background-color: inherit;
width: 500px;
height: 550px;
mask-image: linear-gradient(to bottom, transparent 0%, black 10%, black 90%, transparent 100%);
&-text {
@apply text-2xl cursor-pointer font-bold px-2 py-4;
transition: all 0.3s ease;
background-color: transparent;
span {
background-clip: text !important;
-webkit-background-clip: text !important;
padding-right: 30px;
}
&-tr {
@apply font-normal;
opacity: 0.7;
color: var(--text-color-primary);
}
}
.hover-text {
&:hover {
@apply font-bold opacity-100 rounded-xl;
background-color: var(--hover-bg-color);
span {
color: var(--text-color-active) !important;
}
}
}
}
}
.mobile {
#drawer-target {
@apply flex-col p-4 pt-8 justify-start;
.music-img {
display: none;
}
.music-lrc {
height: calc(100vh - 260px) !important;
width: 100vw;
span {
padding-right: 0px !important;
}
}
.music-lrc-text {
@apply text-xl text-center;
}
}
}
.music-drawer {
transition: none; // 移除之前的过渡效果,现在使用 JS 动画
}
</style>
+561
View File
@@ -0,0 +1,561 @@
<template>
<!-- 展开全屏 -->
<music-full ref="MusicFullRef" v-model:music-full="musicFullVisible" :background="background" />
<!-- 底部播放栏 -->
<div
class="music-play-bar"
:class="
setAnimationClass('animate__bounceInUp') + ' ' + (musicFullVisible ? 'play-bar-opcity' : '')
"
:style="{
color: musicFullVisible
? textColors.theme === 'dark'
? '#000000'
: '#ffffff'
: store.state.theme === 'dark'
? '#ffffff'
: '#000000'
}"
>
<div class="music-time custom-slider">
<n-slider
v-model:value="timeSlider"
:step="1"
:max="allTime"
:min="0"
:format-tooltip="formatTooltip"
></n-slider>
</div>
<div class="play-bar-img-wrapper" @click="setMusicFull">
<n-image
:src="getImgUrl(playMusic?.picUrl, '500y500')"
class="play-bar-img"
lazy
preview-disabled
/>
<div class="hover-arrow">
<div class="hover-content">
<!-- <i class="ri-arrow-up-s-line text-3xl" :class="{ 'ri-arrow-down-s-line': musicFullVisible }"></i> -->
<i
class="text-3xl"
:class="musicFullVisible ? 'ri-arrow-down-s-line' : 'ri-arrow-up-s-line'"
></i>
<span class="hover-text">{{ musicFullVisible ? '收起' : '展开' }}歌词</span>
</div>
</div>
</div>
<div class="music-content">
<div class="music-content-title">
<n-ellipsis class="text-ellipsis" line-clamp="1">
{{ playMusic.name }}
</n-ellipsis>
</div>
<div class="music-content-name">
<n-ellipsis class="text-ellipsis" line-clamp="1">
<span
v-for="(artists, artistsindex) in playMusic.ar || playMusic.song.artists"
:key="artistsindex"
>{{ artists.name
}}{{
artistsindex < (playMusic.ar || playMusic.song.artists).length - 1 ? ' / ' : ''
}}</span
>
</n-ellipsis>
</div>
</div>
<div class="music-buttons">
<div class="music-buttons-prev" @click="handlePrev">
<i class="iconfont icon-prev"></i>
</div>
<div class="music-buttons-play" @click="playMusicEvent">
<i class="iconfont icon" :class="play ? 'icon-stop' : 'icon-play'"></i>
</div>
<div class="music-buttons-next" @click="handleNext">
<i class="iconfont icon-next"></i>
</div>
</div>
<div class="audio-button">
<div class="audio-volume custom-slider">
<div class="volume-icon" @click="mute">
<i class="iconfont" :class="getVolumeIcon"></i>
</div>
<div class="volume-slider">
<n-slider v-model:value="volumeSlider" :step="0.01" :tooltip="false" vertical></n-slider>
</div>
</div>
<n-tooltip v-if="!isMobile" trigger="hover" :z-index="9999999">
<template #trigger>
<i class="iconfont" :class="playModeIcon" @click="togglePlayMode"></i>
</template>
{{ playModeText }}
</n-tooltip>
<n-tooltip v-if="!isMobile" trigger="hover" :z-index="9999999">
<template #trigger>
<i
class="iconfont icon-likefill"
:class="{ 'like-active': isFavorite }"
@click="toggleFavorite"
></i>
</template>
喜欢
</n-tooltip>
<n-tooltip v-if="isElectron" class="music-lyric" trigger="hover" :z-index="9999999">
<template #trigger>
<i
class="iconfont ri-netease-cloud-music-line"
:class="{ 'text-green-500': isLyricWindowOpen }"
@click="openLyricWindow"
></i>
</template>
歌词
</n-tooltip>
<n-popover
trigger="click"
:z-index="99999999"
content-class="music-play"
raw
:show-arrow="false"
:delay="200"
arrow-wrapper-style=" border-radius:1.5rem"
@update-show="scrollToPlayList"
>
<template #trigger>
<n-tooltip trigger="manual" :z-index="9999999">
<template #trigger>
<i class="iconfont icon-list"></i>
</template>
播放列表
</n-tooltip>
</template>
<div class="music-play-list">
<div class="music-play-list-back"></div>
<n-virtual-list ref="palyListRef" :item-size="62" item-resizable :items="playList">
<template #default="{ item }">
<div class="music-play-list-content">
<song-item :key="item.id" :item="item" mini></song-item>
</div>
</template>
</n-virtual-list>
</div>
</n-popover>
</div>
<!-- 播放音乐 -->
</div>
</template>
<script lang="ts" setup>
import { useThrottleFn } from '@vueuse/core';
import { useTemplateRef } from 'vue';
import { useStore } from 'vuex';
import SongItem from '@/components/common/SongItem.vue';
import {
allTime,
isLyricWindowOpen,
nowTime,
openLyric,
sound,
textColors
} from '@/hooks/MusicHook';
import type { SongResult } from '@/type/music';
import { getImgUrl, isMobile, secondToMinute, setAnimationClass, isElectron } from '@/utils';
import MusicFull from './MusicFull.vue';
const store = useStore();
// 播放的音乐信息
const playMusic = computed(() => store.state.playMusic as SongResult);
// 是否播放
const play = computed(() => store.state.play as boolean);
const playList = computed(() => store.state.playList as SongResult[]);
const background = ref('#000');
watch(
() => store.state.playMusic,
async () => {
background.value = playMusic.value.backgroundColor as string;
},
{ immediate: true, deep: true }
);
// 使用 useThrottleFn 创建节流版本的 seek 函数
const throttledSeek = useThrottleFn((value: number) => {
if (!sound.value) return;
sound.value.seek(value);
nowTime.value = value;
}, 50); // 50ms 的节流延迟
// 修改 timeSlider 计算属性
const timeSlider = computed({
get: () => nowTime.value,
set: throttledSeek
});
const formatTooltip = (value: number) => {
return `${secondToMinute(value)} / ${secondToMinute(allTime.value)}`;
};
// 音量条
const audioVolume = ref(
localStorage.getItem('volume') ? parseFloat(localStorage.getItem('volume') as string) : 1
);
const getVolumeIcon = computed(() => {
// 0 静音 ri-volume-mute-line 0.5 ri-volume-down-line 1 ri-volume-up-line
if (audioVolume.value === 0) {
return 'ri-volume-mute-line';
}
if (audioVolume.value <= 0.5) {
return 'ri-volume-down-line';
}
return 'ri-volume-up-line';
});
const volumeSlider = computed({
get: () => audioVolume.value * 100,
set: (value) => {
if (!sound.value) return;
localStorage.setItem('volume', (value / 100).toString());
sound.value.volume(value / 100);
audioVolume.value = value / 100;
}
});
// 静音
const mute = () => {
if (volumeSlider.value === 0) {
volumeSlider.value = 30;
} else {
volumeSlider.value = 0;
}
};
// 播放模式
const playMode = computed(() => store.state.playMode);
const playModeIcon = computed(() => {
switch (playMode.value) {
case 0:
return 'ri-repeat-2-line';
case 1:
return 'ri-repeat-one-line';
case 2:
return 'ri-shuffle-line';
default:
return 'ri-repeat-2-line';
}
});
const playModeText = computed(() => {
switch (playMode.value) {
case 0:
return '列表循环';
case 1:
return '单曲循环';
case 2:
return '随机播放';
default:
return '列表循环';
}
});
// 切换播放模式
const togglePlayMode = () => {
store.commit('togglePlayMode');
};
function handleNext() {
store.commit('nextPlay');
}
function handlePrev() {
store.commit('prevPlay');
}
const MusicFullRef = ref<any>(null);
// 播放暂停按钮事件
const playMusicEvent = async () => {
if (play.value) {
if (sound.value) {
sound.value.pause();
}
store.commit('setPlayMusic', false);
} else {
if (sound.value) {
sound.value.play();
}
store.commit('setPlayMusic', true);
}
};
const musicFullVisible = ref(false);
// 设置musicFull
const setMusicFull = () => {
musicFullVisible.value = !musicFullVisible.value;
};
const palyListRef = useTemplateRef('palyListRef');
const scrollToPlayList = (val: boolean) => {
if (!val) return;
setTimeout(() => {
palyListRef.value?.scrollTo({ top: store.state.playListIndex * 62 });
}, 50);
};
const isFavorite = computed(() => {
return store.state.favoriteList.includes(playMusic.value.id);
});
const toggleFavorite = async (e: Event) => {
e.stopPropagation();
if (isFavorite.value) {
store.commit('removeFromFavorite', playMusic.value.id);
} else {
store.commit('addToFavorite', playMusic.value.id);
}
};
const openLyricWindow = () => {
openLyric();
};
</script>
<style lang="scss" scoped>
.text-ellipsis {
width: 100%;
}
.music-play-bar {
@apply h-20 w-full absolute bottom-0 left-0 flex items-center box-border px-6 py-2 pt-3;
@apply bg-light dark:bg-dark shadow-2xl shadow-gray-300;
z-index: 9999;
animation-duration: 0.5s !important;
.music-content {
width: 160px;
@apply ml-4;
&-title {
@apply text-base;
}
&-name {
@apply text-xs mt-1 opacity-80;
}
}
}
.play-bar-opcity {
@apply bg-transparent !important;
box-shadow: 0 0 20px 5px #0000001d;
}
.play-bar-img {
@apply w-14 h-14 rounded-2xl;
}
.music-buttons {
@apply mx-6 flex-1 flex justify-center;
.iconfont {
@apply text-2xl transition;
@apply hover:text-green-500;
}
.icon {
@apply text-3xl;
@apply hover:text-green-500;
}
@apply flex items-center;
> div {
@apply cursor-pointer;
}
&-play {
@apply flex justify-center items-center w-20 h-12 rounded-full mx-4 transition text-gray-500;
@apply bg-gray-100 bg-opacity-60 hover:bg-gray-200;
}
}
.audio-volume {
@apply flex items-center relative;
&:hover {
.volume-slider {
@apply opacity-100 visible;
}
}
.volume-icon {
@apply cursor-pointer;
}
.iconfont {
@apply text-2xl transition;
@apply hover:text-green-500;
}
.volume-slider {
@apply absolute opacity-0 invisible transition-all duration-300 bottom-[30px] left-1/2 -translate-x-1/2 h-[180px] px-2 py-4 rounded-xl;
@apply bg-light dark:bg-gray-800;
@apply border border-gray-200 dark:border-gray-700;
}
}
.audio-button {
@apply flex items-center mx-4;
.iconfont {
@apply text-2xl transition cursor-pointer m-4;
@apply hover:text-green-500;
}
}
.music-play {
&-list {
height: 50vh;
width: 300px;
@apply relative rounded-3xl overflow-hidden py-2;
&-back {
backdrop-filter: blur(20px);
@apply absolute top-0 left-0 w-full h-full;
@apply bg-light dark:bg-black bg-opacity-75;
}
&-content {
@apply mx-2;
}
}
}
.mobile {
.music-play-bar {
@apply px-4;
bottom: 70px;
}
.music-time {
display: none;
}
.ri-netease-cloud-music-line {
display: none;
}
.audio-volume {
display: none;
}
.audio-button {
@apply mx-0;
}
.music-buttons {
@apply m-0;
&-prev,
&-next {
display: none;
}
&-play {
@apply m-0;
}
}
.music-content {
flex: 1;
}
}
// 自定义滑块样式
.custom-slider {
:deep(.n-slider) {
--n-rail-height: 4px;
--n-rail-color: theme('colors.gray.200');
--n-rail-color-dark: theme('colors.gray.700');
--n-fill-color: theme('colors.green.500');
--n-handle-size: 12px;
--n-handle-color: theme('colors.green.500');
&.n-slider--vertical {
height: 100%;
.n-slider-rail {
width: 4px;
}
&:hover {
.n-slider-rail {
width: 6px;
}
.n-slider-handle {
width: 14px;
height: 14px;
}
}
}
.n-slider-rail {
@apply overflow-hidden transition-all duration-200;
@apply bg-gray-500 dark:bg-dark-300 bg-opacity-10 !important;
}
.n-slider-handle {
@apply transition-all duration-200;
opacity: 0;
}
&:hover .n-slider-handle {
opacity: 1;
}
}
}
.play-bar-img-wrapper {
@apply relative cursor-pointer w-14 h-14;
.hover-arrow {
@apply absolute inset-0 flex items-center justify-center opacity-0 transition-opacity duration-300 rounded-2xl;
background: rgba(0, 0, 0, 0.5);
.hover-content {
@apply flex flex-col items-center justify-center;
i {
@apply text-white mb-0.5;
}
.hover-text {
@apply text-white text-xs scale-90;
}
}
}
&:hover {
.hover-arrow {
@apply opacity-100;
}
}
}
.tooltip-content {
@apply text-sm py-1 px-2;
}
.play-bar-img {
@apply w-14 h-14 rounded-2xl;
}
.like-active {
@apply text-red-500 hover:text-red-600 !important;
}
.icon-loop,
.icon-single-loop {
font-size: 1.5rem;
}
.music-time .n-slider {
position: absolute;
top: 0;
left: 0;
padding: 0;
border-radius: 0;
}
</style>
@@ -0,0 +1,319 @@
<template>
<div class="search-box flex">
<div class="search-box-input flex-1">
<n-input
v-model:value="searchValue"
size="medium"
round
:placeholder="hotSearchKeyword"
class="border dark:border-gray-600 border-gray-200"
@keydown.enter="search"
>
<template #prefix>
<i class="iconfont icon-search"></i>
</template>
<template #suffix>
<n-dropdown trigger="hover" :options="searchTypeOptions" @select="selectSearchType">
<div class="w-20 px-3 flex justify-between items-center">
<div>
{{ searchTypeOptions.find((item) => item.key === store.state.searchType)?.label }}
</div>
<i class="iconfont icon-xiasanjiaoxing"></i>
</div>
</n-dropdown>
</template>
</n-input>
</div>
<n-popover trigger="hover" placement="bottom" :show-arrow="false" raw>
<template #trigger>
<div class="user-box">
<n-avatar
v-if="store.state.user"
class="cursor-pointer"
circle
size="medium"
:src="getImgUrl(store.state.user.avatarUrl)"
@click="selectItem('user')"
/>
<div v-else class="mx-2 rounded-full cursor-pointer text-sm" @click="toLogin">登录</div>
</div>
</template>
<div class="user-popover">
<div v-if="store.state.user" class="user-header" @click="selectItem('user')">
<n-avatar circle size="small" :src="getImgUrl(store.state.user?.avatarUrl)" />
<span class="username">{{ store.state.user?.nickname || 'Theodore' }}</span>
</div>
<div class="menu-items">
<div v-if="!store.state.user" class="menu-item" @click="toLogin">
<i class="iconfont ri-login-box-line"></i>
<span>去登录</span>
</div>
<!-- 切换主题 -->
<div class="menu-item" @click="selectItem('set')">
<i class="iconfont ri-settings-3-line"></i>
<span>设置</span>
</div>
<div class="menu-item">
<i class="iconfont" :class="isDarkTheme ? 'ri-moon-line' : 'ri-sun-line'"></i>
<span>主题</span>
<n-switch v-model:value="isDarkTheme" class="ml-auto">
<template #checked>
<i class="ri-moon-line"></i>
</template>
<template #unchecked>
<i class="ri-sun-line"></i>
</template>
</n-switch>
</div>
<div class="menu-item" @click="restartApp">
<i class="iconfont ri-restart-line"></i>
<span>重启</span>
</div>
<div class="menu-item" @click="toGithubRelease">
<i class="iconfont ri-refresh-line"></i>
<span>当前版本</span>
<div class="version-info">
<span class="version-number">{{ updateInfo.currentVersion }}</span>
<n-tag v-if="updateInfo.hasUpdate" type="success" size="small" class="ml-1">
New {{ updateInfo.latestVersion }}
</n-tag>
</div>
</div>
</div>
</div>
</n-popover>
<coffee :alipay-q-r="alipay" :wechat-q-r="wechat">
<div class="github" @click="toGithub">
<i class="ri-github-fill"></i>
</div>
</coffee>
</div>
</template>
<script lang="ts" setup>
import { onMounted, ref, watchEffect, computed } from 'vue';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { getSearchKeyword } from '@/api/home';
import { getUserDetail, logout } from '@/api/login';
import alipay from '@/assets/alipay.png';
import wechat from '@/assets/wechat.png';
import Coffee from '@/components/Coffee.vue';
import { SEARCH_TYPES, USER_SET_OPTIONS } from '@/const/bar-const';
import { getImgUrl, checkUpdate } from '@/utils';
import config from '../../../../package.json';
const router = useRouter();
const store = useStore();
const userSetOptions = ref(USER_SET_OPTIONS);
// 推荐热搜词
const hotSearchKeyword = ref('搜索点什么吧...');
const hotSearchValue = ref('');
const loadHotSearchKeyword = async () => {
const { data } = await getSearchKeyword();
hotSearchKeyword.value = data.data.showKeyword;
hotSearchValue.value = data.data.realkeyword;
};
const loadPage = async () => {
const token = localStorage.getItem('token');
if (!token) return;
const { data } = await getUserDetail();
store.state.user = data.profile;
localStorage.setItem('user', JSON.stringify(data.profile));
};
loadPage();
watchEffect(() => {
if (store.state.user) {
userSetOptions.value = USER_SET_OPTIONS;
} else {
userSetOptions.value = USER_SET_OPTIONS.filter((item) => item.key !== 'logout');
}
});
const restartApp = () => {
window.electron.ipcRenderer.send('restart');
};
const toLogin = () => {
router.push('/login');
};
// 页面初始化
onMounted(() => {
loadHotSearchKeyword();
loadPage();
checkForUpdates();
});
const isDarkTheme = computed({
get: () => store.state.theme === 'dark',
set: () => store.commit('toggleTheme')
});
// 搜索词
const searchValue = ref('');
const search = () => {
const { value } = searchValue;
if (value === '') {
searchValue.value = hotSearchValue.value;
return;
}
if (router.currentRoute.value.path === '/search') {
store.state.searchValue = value;
return;
}
router.push({
path: '/search',
query: {
keyword: value
}
});
};
const selectSearchType = (key: number) => {
store.state.searchType = key;
};
const searchTypeOptions = ref(SEARCH_TYPES);
const selectItem = async (key: string) => {
// switch 判断
switch (key) {
case 'logout':
logout().then(() => {
store.state.user = null;
localStorage.clear();
router.push('/login');
});
break;
case 'login':
router.push('/login');
break;
case 'set':
router.push('/set');
break;
case 'user':
router.push('/user');
break;
default:
}
};
const toGithub = () => {
window.open('https://github.com/algerkong/AlgerMusicPlayer', '_blank');
};
const updateInfo = ref({
hasUpdate: false,
latestVersion: '',
currentVersion: config.version,
releaseInfo: null
});
const checkForUpdates = async () => {
try {
const result = await checkUpdate();
updateInfo.value = result;
} catch (error) {
console.error('检查更新失败:', error);
}
};
const toGithubRelease = () => {
if (updateInfo.value.hasUpdate) {
window.open('https://github.com/algerkong/AlgerMusicPlayer/releases/latest', '_blank');
} else {
window.open('https://github.com/algerkong/AlgerMusicPlayer/releases', '_blank');
}
};
</script>
<style lang="scss" scoped>
.user-box {
@apply ml-4 flex text-lg justify-center items-center rounded-full transition-colors duration-200;
@apply border dark:border-gray-600 border-gray-200 hover:border-gray-400 dark:hover:border-gray-400;
@apply bg-light dark:bg-gray-800;
}
.search-box {
@apply pb-4 pr-4;
}
.search-box-input {
@apply relative;
:deep(.n-input) {
@apply bg-gray-50 dark:bg-black;
.n-input__input-el {
@apply text-gray-900 dark:text-white;
}
.n-input__prefix {
@apply text-gray-500 dark:text-gray-400;
}
}
}
.mobile {
.search-box {
@apply pl-4;
}
}
.github {
@apply cursor-pointer text-gray-900 dark:text-gray-100 hover:text-gray-600 dark:hover:text-gray-400 text-xl ml-4 rounded-full flex justify-center items-center px-2 h-full;
@apply border dark:border-gray-600 border-gray-200 bg-light dark:bg-black;
}
.user-popover {
@apply min-w-[280px] p-0 rounded-xl overflow-hidden;
@apply bg-light dark:bg-black;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
.user-header {
@apply flex items-center gap-2 p-3 cursor-pointer;
@apply border-b dark:border-gray-700 border-gray-100 hover:bg-gray-100 dark:hover:bg-gray-700;
.username {
@apply text-sm font-medium text-gray-900 dark:text-gray-200;
}
}
.menu-items {
@apply py-1;
.menu-item {
@apply flex items-center px-3 py-2 text-sm cursor-pointer;
@apply text-gray-700 dark:text-gray-300;
transition: background-color 0.2s;
&:hover {
@apply bg-gray-100 dark:bg-gray-700;
}
i {
@apply mr-1 text-lg text-gray-500 dark:text-gray-400;
}
.version-info {
@apply ml-auto flex items-center;
.version-number {
@apply text-xs px-2 py-0.5 rounded;
@apply bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300;
}
}
}
}
}
</style>
@@ -0,0 +1,71 @@
<template>
<div id="title-bar" @mousedown="drag">
<div id="title">Alger Music</div>
<div id="buttons">
<button @click="minimize">
<i class="iconfont icon-minisize"></i>
</button>
<button @click="close">
<i class="iconfont icon-close"></i>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { useDialog } from 'naive-ui';
import { isElectron } from '@/utils';
const dialog = useDialog();
const minimize = () => {
if (!isElectron) {
return;
}
window.api.minimize();
};
const close = () => {
if (!isElectron) {
return;
}
dialog.warning({
title: '提示',
content: '确定要退出吗?',
positiveText: '最小化',
negativeText: '关闭',
onPositiveClick: () => {
window.api.minimize();
},
onNegativeClick: () => {
window.api.close();
}
});
};
const drag = (event: MouseEvent) => {
if (!isElectron) {
return;
}
window.api.dragStart(event as unknown as string);
};
</script>
<style scoped lang="scss">
#title-bar {
-webkit-app-region: drag;
@apply flex justify-between px-6 py-2 select-none relative;
@apply text-dark dark:text-white;
z-index: 9999999;
}
#buttons {
@apply flex gap-4;
-webkit-app-region: no-drag;
}
button {
@apply text-gray-600 dark:text-gray-400 hover:text-green-500;
}
</style>
+5
View File
@@ -0,0 +1,5 @@
import AppMenu from './AppMenu.vue';
import PlayBar from './PlayBar.vue';
import SearchBar from './SearchBar.vue';
export { AppMenu, PlayBar, SearchBar };
@@ -0,0 +1,24 @@
<template>
<div class="lrc-full">
{{ lrcIndex }}
</div>
</template>
<script setup lang="ts">
defineProps({
lrcList: {
type: Array,
default: () => []
},
lrcIndex: {
type: Number,
default: 0
},
lrcTime: {
type: Number,
default: 0
}
});
</script>
<style scoped lang="scss"></style>
+22
View File
@@ -0,0 +1,22 @@
import 'vfonts/Lato.css';
import 'vfonts/FiraCode.css';
// tailwind css
import './index.css';
import 'remixicon/fonts/remixicon.css';
import { createApp } from 'vue';
import router from '@/router';
import store from '@/store';
import App from './App.vue';
import directives from './directive';
const app = createApp(App);
Object.keys(directives).forEach((key: string) => {
app.directive(key, directives[key as keyof typeof directives]);
});
app.use(router);
app.use(store);
app.mount('#app');
+91
View File
@@ -0,0 +1,91 @@
const layoutRouter = [
{
path: '/',
name: 'home',
meta: {
title: '首页',
icon: 'icon-Home',
keepAlive: true,
isMobile: true
},
component: () => import('@/views/home/index.vue')
},
{
path: '/search',
name: 'search',
meta: {
title: '搜索',
noScroll: true,
icon: 'icon-Search',
keepAlive: true,
isMobile: true
},
component: () => import('@/views/search/index.vue')
},
{
path: '/list',
name: 'list',
meta: {
title: '歌单',
icon: 'icon-Paper',
keepAlive: true,
isMobile: true
},
component: () => import('@/views/list/index.vue')
},
{
path: '/mv',
name: 'mv',
meta: {
title: 'MV',
icon: 'icon-recordfill',
keepAlive: true,
isMobile: true
},
component: () => import('@/views/mv/index.vue')
},
// {
// path: '/history',
// name: 'history',
// meta: {
// title: '历史',
// icon: 'icon-a-TicketStar',
// keepAlive: true,
// },
// component: () => import('@/views/history/index.vue'),
// },
{
path: '/history',
name: 'history',
component: () => import('@/views/historyAndFavorite/index.vue'),
meta: {
title: '收藏历史',
icon: 'icon-a-TicketStar',
keepAlive: true
}
},
{
path: '/user',
name: 'user',
meta: {
title: '用户',
icon: 'icon-Profile',
keepAlive: true,
noScroll: true,
isMobile: true
},
component: () => import('@/views/user/index.vue')
},
{
path: '/set',
name: 'set',
meta: {
title: '设置',
icon: 'ri-settings-3-fill',
keepAlive: true,
noScroll: true
},
component: () => import('@/views/set/index.vue')
}
];
export default layoutRouter;
+43
View File
@@ -0,0 +1,43 @@
import { createRouter, createWebHashHistory } from 'vue-router';
import AppLayout from '@/layout/AppLayout.vue';
import homeRouter from '@/router/home';
const loginRouter = {
path: '/login',
name: 'login',
mate: {
keepAlive: true,
title: '登录',
icon: 'icon-Home'
},
component: () => import('@/views/login/index.vue')
};
const setRouter = {
path: '/set',
name: 'set',
mate: {
keepAlive: true,
title: '设置',
icon: 'icon-Home'
},
component: () => import('@/views/set/index.vue')
};
const routes = [
{
path: '/',
component: AppLayout,
children: [...homeRouter, loginRouter, setRouter]
},
{
path: '/lyric',
component: () => import('@/views/lyric/index.vue')
}
];
export default createRouter({
routes,
history: createWebHashHistory()
});
+57
View File
@@ -0,0 +1,57 @@
import { Howl } from 'howler';
class AudioService {
private currentSound: Howl | null = null;
play(url: string) {
if (this.currentSound) {
this.currentSound.unload();
}
this.currentSound = null;
this.currentSound = new Howl({
src: [url],
html5: true,
autoplay: true,
volume: localStorage.getItem('volume')
? parseFloat(localStorage.getItem('volume') as string)
: 1
});
return this.currentSound;
}
getCurrentSound() {
return this.currentSound;
}
stop() {
if (this.currentSound) {
this.currentSound.stop();
this.currentSound.unload();
this.currentSound = null;
}
}
// 监听播放
onPlay(callback: () => void) {
if (this.currentSound) {
this.currentSound.on('play', callback);
}
}
// 监听暂停
onPause(callback: () => void) {
if (this.currentSound) {
this.currentSound.on('pause', callback);
}
}
// 监听结束
onEnd(callback: () => void) {
if (this.currentSound) {
this.currentSound.on('end', callback);
}
}
}
export const audioService = new AudioService();
+6
View File
@@ -0,0 +1,6 @@
declare module '*.vue' {
import { DefineComponent } from 'vue';
const component: DefineComponent<{}, {}, any>;
export default component;
}
+147
View File
@@ -0,0 +1,147 @@
import { createStore } from 'vuex';
import { useMusicListHook } from '@/hooks/MusicListHook';
import homeRouter from '@/router/home';
import type { SongResult } from '@/type/music';
import { applyTheme, getCurrentTheme, ThemeType } from '@/utils/theme';
import { isElectron } from '@/utils';
// 默认设置
const defaultSettings = {
isProxy: false,
noAnimate: false,
animationSpeed: 1,
author: 'Alger',
authorUrl: 'https://github.com/algerkong'
};
function getLocalStorageItem<T>(key: string, defaultValue: T): T {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : defaultValue;
}
interface State {
menus: any[];
play: boolean;
isPlay: boolean;
playMusic: SongResult;
playMusicUrl: string;
user: any;
playList: SongResult[];
playListIndex: number;
setData: any;
lyric: any;
isMobile: boolean;
searchValue: string;
searchType: number;
favoriteList: number[];
playMode: number;
theme: ThemeType;
}
const state: State = {
menus: homeRouter,
play: false,
isPlay: false,
playMusic: {} as SongResult,
playMusicUrl: '',
user: getLocalStorageItem('user', null),
playList: [],
playListIndex: 0,
setData: defaultSettings,
lyric: {},
isMobile: false,
searchValue: '',
searchType: 1,
favoriteList: getLocalStorageItem('favoriteList', []),
playMode: getLocalStorageItem('playMode', 0),
theme: getCurrentTheme()
};
const { handlePlayMusic, nextPlay, prevPlay } = useMusicListHook();
const mutations = {
setMenus(state: State, menus: any[]) {
state.menus = menus;
},
async setPlay(state: State, playMusic: SongResult) {
await handlePlayMusic(state, playMusic);
},
setIsPlay(state: State, isPlay: boolean) {
state.isPlay = isPlay;
},
setPlayMusic(state: State, play: boolean) {
state.play = play;
},
setPlayList(state: State, playList: SongResult[]) {
state.playListIndex = playList.findIndex((item) => item.id === state.playMusic.id);
state.playList = playList;
},
async nextPlay(state: State) {
await nextPlay(state);
},
async prevPlay(state: State) {
await prevPlay(state);
},
setSetData(state: State, setData: any) {
state.setData = setData;
if (isElectron) {
// (window as any).electron.ipcRenderer.setStoreValue(
// 'set',
// JSON.parse(JSON.stringify(setData))
// );
window.electron.ipcRenderer.send('set-store-value', 'set', JSON.parse(JSON.stringify(setData)));
} else {
localStorage.setItem('appSettings', JSON.stringify(setData));
}
},
addToFavorite(state: State, songId: number) {
if (!state.favoriteList.includes(songId)) {
state.favoriteList = [songId, ...state.favoriteList];
localStorage.setItem('favoriteList', JSON.stringify(state.favoriteList));
}
},
removeFromFavorite(state: State, songId: number) {
state.favoriteList = state.favoriteList.filter((id) => id !== songId);
localStorage.setItem('favoriteList', JSON.stringify(state.favoriteList));
},
togglePlayMode(state: State) {
state.playMode = (state.playMode + 1) % 3;
localStorage.setItem('playMode', JSON.stringify(state.playMode));
},
toggleTheme(state: State) {
state.theme = state.theme === 'dark' ? 'light' : 'dark';
applyTheme(state.theme);
}
};
const actions = {
initializeSettings({ commit }: { commit: any }) {
if (isElectron) {
// const setData = (window as any).electron.ipcRenderer.getStoreValue('set');
const setData = window.electron.ipcRenderer.sendSync('get-store-value', 'set');
commit('setSetData', setData || defaultSettings);
} else {
const savedSettings = localStorage.getItem('appSettings');
if (savedSettings) {
commit('setSetData', {
...defaultSettings,
...JSON.parse(savedSettings)
});
} else {
commit('setSetData', defaultSettings);
}
}
},
initializeTheme({ state }: { state: State }) {
applyTheme(state.theme);
}
};
const store = createStore({
state,
mutations,
actions
});
export default store;
+65
View File
@@ -0,0 +1,65 @@
export interface IAlbumNew {
code: number;
albums: Album[];
}
export interface Album {
name: string;
id: number;
type: string;
size: number;
picId: number;
blurPicUrl: string;
companyId: number;
pic: number;
picUrl: string;
publishTime: number;
description: string;
tags: string;
company: string;
briefDesc: string;
artist: Artist;
songs?: any;
alias: string[];
status: number;
copyrightId: number;
commentThreadId: string;
artists: Artist2[];
paid: boolean;
onSale: boolean;
picId_str: string;
}
interface Artist2 {
name: string;
id: number;
picId: number;
img1v1Id: number;
briefDesc: string;
picUrl: string;
img1v1Url: string;
albumSize: number;
alias: any[];
trans: string;
musicSize: number;
topicPerson: number;
img1v1Id_str: string;
}
interface Artist {
name: string;
id: number;
picId: number;
img1v1Id: number;
briefDesc: string;
picUrl: string;
img1v1Url: string;
albumSize: number;
alias: string[];
trans: string;
musicSize: number;
topicPerson: number;
picId_str?: string;
img1v1Id_str: string;
transNames?: string[];
}
+168
View File
@@ -0,0 +1,168 @@
export interface IDayRecommend {
dailySongs: DailySong[];
orderSongs: any[];
recommendReasons: RecommendReason[];
mvResourceInfos: null;
}
interface RecommendReason {
songId: number;
reason: string;
reasonId: string;
targetUrl: null;
}
interface DailySong {
name: string;
id: number;
pst: number;
t: number;
ar: Ar[];
alia: string[];
pop: number;
st: number;
rt: null | string;
fee: number;
v: number;
crbt: null;
cf: string;
al: Al;
dt: number;
h: H;
m: H;
l: H;
sq: H | null;
hr: H | null;
a: null;
cd: string;
no: number;
rtUrl: null;
ftype: number;
rtUrls: any[];
djId: number;
copyright: number;
s_id: number;
mark: number;
originCoverType: number;
originSongSimpleData: OriginSongSimpleDatum | null;
tagPicList: null;
resourceState: boolean;
version: number;
songJumpInfo: null;
entertainmentTags: null;
single: number;
noCopyrightRcmd: null;
rtype: number;
rurl: null;
mst: number;
cp: number;
mv: number;
publishTime: number;
reason: null | string;
videoInfo: VideoInfo;
recommendReason: null | string;
privilege: Privilege;
alg: string;
tns?: string[];
s_ctrp?: string;
}
interface Privilege {
id: number;
fee: number;
payed: number;
realPayed: number;
st: number;
pl: number;
dl: number;
sp: number;
cp: number;
subp: number;
cs: boolean;
maxbr: number;
fl: number;
pc: null;
toast: boolean;
flag: number;
paidBigBang: boolean;
preSell: boolean;
playMaxbr: number;
downloadMaxbr: number;
maxBrLevel: string;
playMaxBrLevel: string;
downloadMaxBrLevel: string;
plLevel: string;
dlLevel: string;
flLevel: string;
rscl: null;
freeTrialPrivilege: FreeTrialPrivilege;
rightSource: number;
chargeInfoList: ChargeInfoList[];
}
interface ChargeInfoList {
rate: number;
chargeUrl: null;
chargeMessage: null;
chargeType: number;
}
interface FreeTrialPrivilege {
resConsumable: boolean;
userConsumable: boolean;
listenType: number;
cannotListenReason: number;
playReason: null;
}
interface VideoInfo {
moreThanOne: boolean;
video: Video | null;
}
interface Video {
vid: string;
type: number;
title: string;
playTime: number;
coverUrl: string;
publishTime: number;
artists: null;
alias: null;
}
interface OriginSongSimpleDatum {
songId: number;
name: string;
artists: Artist[];
albumMeta: Artist;
}
interface Artist {
id: number;
name: string;
}
interface H {
br: number;
fid: number;
size: number;
vd: number;
sr: number;
}
interface Al {
id: number;
name: string;
picUrl: string;
tns: string[];
pic_str?: string;
pic: number;
}
interface Ar {
id: number;
name: string;
tns: any[];
alias: any[];
}
+5
View File
@@ -0,0 +1,5 @@
export interface IData<T> {
code: number;
data: T;
result: T;
}
+147
View File
@@ -0,0 +1,147 @@
export interface IList {
playlists: Playlist[];
code: number;
more: boolean;
lasttime: number;
total: number;
}
export interface Playlist {
name: string;
id: number;
trackNumberUpdateTime: number;
status: number;
userId: number;
createTime: number;
updateTime: number;
subscribedCount: number;
trackCount: number;
cloudTrackCount: number;
coverImgUrl: string;
coverImgId: number;
description: string;
tags: string[];
playCount: number;
trackUpdateTime: number;
specialType: number;
totalDuration: number;
creator: Creator;
tracks?: any;
subscribers: Subscriber[];
subscribed: boolean;
commentThreadId: string;
newImported: boolean;
adType: number;
highQuality: boolean;
privacy: number;
ordered: boolean;
anonimous: boolean;
coverStatus: number;
recommendInfo?: any;
shareCount: number;
coverImgId_str?: string;
commentCount: number;
copywriter: string;
tag: string;
}
interface Subscriber {
defaultAvatar: boolean;
province: number;
authStatus: number;
followed: boolean;
avatarUrl: string;
accountStatus: number;
gender: number;
city: number;
birthday: number;
userId: number;
userType: number;
nickname: string;
signature: string;
description: string;
detailDescription: string;
avatarImgId: number;
backgroundImgId: number;
backgroundUrl: string;
authority: number;
mutual: boolean;
expertTags?: any;
experts?: any;
djStatus: number;
vipType: number;
remarkName?: any;
authenticationTypes: number;
avatarDetail?: any;
avatarImgIdStr: string;
backgroundImgIdStr: string;
anchor: boolean;
avatarImgId_str?: string;
}
interface Creator {
defaultAvatar: boolean;
province: number;
authStatus: number;
followed: boolean;
avatarUrl: string;
accountStatus: number;
gender: number;
city: number;
birthday: number;
userId: number;
userType: number;
nickname: string;
signature: string;
description: string;
detailDescription: string;
avatarImgId: number;
backgroundImgId: number;
backgroundUrl: string;
authority: number;
mutual: boolean;
expertTags?: string[];
experts?: Expert;
djStatus: number;
vipType: number;
remarkName?: any;
authenticationTypes: number;
avatarDetail?: AvatarDetail;
avatarImgIdStr: string;
backgroundImgIdStr: string;
anchor: boolean;
avatarImgId_str?: string;
}
interface AvatarDetail {
userType: number;
identityLevel: number;
identityIconUrl: string;
}
interface Expert {
'2': string;
'1'?: string;
}
// 推荐歌单
export interface IRecommendList {
hasTaste: boolean;
code: number;
category: number;
result: IRecommendItem[];
}
export interface IRecommendItem {
id: number;
type: number;
name: string;
copywriter: string;
picUrl: string;
canDislike: boolean;
trackNumberUpdateTime: number;
playCount: number;
trackCount: number;
highQuality: boolean;
alg: string;
}
+203
View File
@@ -0,0 +1,203 @@
export interface IListDetail {
code: number;
relatedVideos?: any;
playlist: Playlist;
urls?: any;
privileges: Privilege[];
sharedPrivilege?: any;
resEntrance?: any;
}
interface Privilege {
id: number;
fee: number;
payed: number;
realPayed: number;
st: number;
pl: number;
dl: number;
sp: number;
cp: number;
subp: number;
cs: boolean;
maxbr: number;
fl: number;
pc?: any;
toast: boolean;
flag: number;
paidBigBang: boolean;
preSell: boolean;
playMaxbr: number;
downloadMaxbr: number;
rscl?: any;
freeTrialPrivilege: FreeTrialPrivilege;
chargeInfoList: ChargeInfoList[];
}
interface ChargeInfoList {
rate: number;
chargeUrl?: any;
chargeMessage?: any;
chargeType: number;
}
interface FreeTrialPrivilege {
resConsumable: boolean;
userConsumable: boolean;
}
export interface Playlist {
id: number;
name: string;
coverImgId: number;
coverImgUrl: string;
coverImgId_str: string;
adType: number;
userId: number;
createTime: number;
status: number;
opRecommend: boolean;
highQuality: boolean;
newImported: boolean;
updateTime: number;
trackCount: number;
specialType: number;
privacy: number;
trackUpdateTime: number;
commentThreadId: string;
playCount: number;
trackNumberUpdateTime: number;
subscribedCount: number;
cloudTrackCount: number;
ordered: boolean;
description: string;
tags: string[];
updateFrequency?: any;
backgroundCoverId: number;
backgroundCoverUrl?: any;
titleImage: number;
titleImageUrl?: any;
englishTitle?: any;
officialPlaylistType?: any;
subscribers: Subscriber[];
subscribed: boolean;
creator: Subscriber;
tracks: Track[];
videoIds?: any;
videos?: any;
trackIds: TrackId[];
shareCount: number;
commentCount: number;
remixVideo?: any;
sharedUsers?: any;
historySharedUsers?: any;
}
interface TrackId {
id: number;
v: number;
t: number;
at: number;
alg?: any;
uid: number;
rcmdReason: string;
}
interface Track {
name: string;
id: number;
pst: number;
t: number;
ar: Ar[];
alia: string[];
pop: number;
st: number;
rt?: string;
fee: number;
v: number;
crbt?: any;
cf: string;
al: Al;
dt: number;
h: H;
m: H;
l?: H;
a?: any;
cd: string;
no: number;
rtUrl?: any;
ftype: number;
rtUrls: any[];
djId: number;
copyright: number;
s_id: number;
mark: number;
originCoverType: number;
originSongSimpleData?: any;
single: number;
noCopyrightRcmd?: any;
mst: number;
cp: number;
mv: number;
rtype: number;
rurl?: any;
publishTime: number;
tns?: string[];
}
interface H {
br: number;
fid: number;
size: number;
vd: number;
}
interface Al {
id: number;
name: string;
picUrl: string;
tns: any[];
pic_str?: string;
pic: number;
}
interface Ar {
id: number;
name: string;
tns: any[];
alias: any[];
}
interface Subscriber {
defaultAvatar: boolean;
province: number;
authStatus: number;
followed: boolean;
avatarUrl: string;
accountStatus: number;
gender: number;
city: number;
birthday: number;
userId: number;
userType: number;
nickname: string;
signature: string;
description: string;
detailDescription: string;
avatarImgId: number;
backgroundImgId: number;
backgroundUrl: string;
authority: number;
mutual: boolean;
expertTags?: any;
experts?: any;
djStatus: number;
vipType: number;
remarkName?: any;
authenticationTypes: number;
avatarDetail?: any;
backgroundImgIdStr: string;
anchor: boolean;
avatarImgIdStr: string;
avatarImgId_str: string;
}
+14
View File
@@ -0,0 +1,14 @@
export interface ILyric {
sgc: boolean;
sfy: boolean;
qfy: boolean;
lrc: Lrc;
klyric: Lrc;
tlyric: Lrc;
code: number;
}
interface Lrc {
version: number;
lyric: string;
}
+216
View File
@@ -0,0 +1,216 @@
export interface IRecommendMusic {
code: number;
category: number;
result: SongResult[];
}
export interface ILyricText {
text: string;
trText: string;
}
export interface ILyric {
lrcTimeArray: number[];
lrcArray: ILyricText[];
}
export interface SongResult {
id: number;
type: number;
name: string;
copywriter?: any;
picUrl: string;
canDislike: boolean;
trackNumberUpdateTime?: any;
song: Song;
alg: string;
count?: number;
playLoading?: boolean;
ar?: Artist[];
al?: Album;
backgroundColor?: string;
primaryColor?: string;
playMusicUrl?: string;
lyric?: ILyric;
}
export interface Song {
name: string;
id: number;
position: number;
alias: string[];
status: number;
fee: number;
copyrightId: number;
disc: string;
no: number;
artists: Artist[];
album: Album;
starred: boolean;
popularity: number;
score: number;
starredNum: number;
duration: number;
playedNum: number;
dayPlays: number;
hearTime: number;
ringtone: string;
crbt?: any;
audition?: any;
copyFrom: string;
commentThreadId: string;
rtUrl?: any;
ftype: number;
rtUrls: any[];
copyright: number;
transName?: any;
sign?: any;
mark: number;
originCoverType: number;
originSongSimpleData?: any;
single: number;
noCopyrightRcmd?: any;
rtype: number;
rurl?: any;
mvid: number;
bMusic: BMusic;
mp3Url?: any;
hMusic: BMusic;
mMusic: BMusic;
lMusic: BMusic;
exclusive: boolean;
privilege: Privilege;
count?: number;
playLoading?: boolean;
picUrl?: string;
ar: Artist[];
}
interface Privilege {
id: number;
fee: number;
payed: number;
st: number;
pl: number;
dl: number;
sp: number;
cp: number;
subp: number;
cs: boolean;
maxbr: number;
fl: number;
toast: boolean;
flag: number;
preSell: boolean;
playMaxbr: number;
downloadMaxbr: number;
rscl?: any;
freeTrialPrivilege: FreeTrialPrivilege;
chargeInfoList: ChargeInfoList[];
}
interface ChargeInfoList {
rate: number;
chargeUrl?: any;
chargeMessage?: any;
chargeType: number;
}
interface FreeTrialPrivilege {
resConsumable: boolean;
userConsumable: boolean;
}
interface BMusic {
name?: any;
id: number;
size: number;
extension: string;
sr: number;
dfsId: number;
bitrate: number;
playTime: number;
volumeDelta: number;
}
interface Album {
name: string;
id: number;
type: string;
size: number;
picId: number;
blurPicUrl: string;
companyId: number;
pic: number;
picUrl: string;
publishTime: number;
description: string;
tags: string;
company: string;
briefDesc: string;
artist: Artist;
songs: any[];
alias: string[];
status: number;
copyrightId: number;
commentThreadId: string;
artists: Artist[];
subType: string;
transName?: any;
onSale: boolean;
mark: number;
picId_str: string;
}
interface Artist {
name: string;
id: number;
picId: number;
img1v1Id: number;
briefDesc: string;
picUrl: string;
img1v1Url: string;
albumSize: number;
alias: any[];
trans: string;
musicSize: number;
topicPerson: number;
}
export interface IPlayMusicUrl {
data: Datum[];
code: number;
}
interface Datum {
id: number;
url: string;
br: number;
size: number;
md5: string;
code: number;
expi: number;
type: string;
gain: number;
fee: number;
uf?: any;
payed: number;
flag: number;
canExtend: boolean;
freeTrialInfo?: any;
level: string;
encodeType: string;
freeTrialPrivilege: FreeTrialPrivilege;
freeTimeTrialPrivilege: FreeTimeTrialPrivilege;
urlSource: number;
}
interface FreeTimeTrialPrivilege {
resConsumable: boolean;
userConsumable: boolean;
type: number;
remainTime: number;
}
interface FreeTrialPrivilege {
resConsumable: boolean;
userConsumable: boolean;
}
+112
View File
@@ -0,0 +1,112 @@
export interface IMvItem {
id: number;
cover: string;
name: string;
playCount: number;
briefDesc?: any;
desc?: any;
artistName: string;
artistId: number;
duration: number;
mark: number;
mv: IMvData;
lastRank: number;
score: number;
subed: boolean;
artists: Artist[];
transNames?: string[];
alias?: string[];
}
export interface IMvData {
authId: number;
status: number;
id: number;
title: string;
subTitle: string;
appTitle: string;
aliaName: string;
transName: string;
pic4v3: number;
pic16v9: number;
caption: number;
captionLanguage: string;
style?: any;
mottos: string;
oneword?: any;
appword: string;
stars?: any;
desc: string;
area: string;
type: string;
subType: string;
neteaseonly: number;
upban: number;
topWeeks: string;
publishTime: string;
online: number;
score: number;
plays: number;
monthplays: number;
weekplays: number;
dayplays: number;
fee: number;
artists: Artist[];
videos: Video[];
}
interface Video {
tagSign: TagSign;
tag: string;
url: string;
duration: number;
size: number;
width: number;
height: number;
container: string;
md5: string;
check: boolean;
}
interface TagSign {
br: number;
type: string;
tagSign: string;
resolution: number;
mvtype: string;
}
interface Artist {
id: number;
name: string;
}
// {
// "id": 14686812,
// "url": "http://vodkgeyttp8.vod.126.net/cloudmusic/e18b/core/aa57/6f56150a35613ef77fc70b253bea4977.mp4?wsSecret=84a301277e05143de1dd912d2a4dbb0d&wsTime=1703668700",
// "r": 1080,
// "size": 215391070,
// "md5": "",
// "code": 200,
// "expi": 3600,
// "fee": 0,
// "mvFee": 0,
// "st": 0,
// "promotionVo": null,
// "msg": ""
// }
export interface IMvUrlData {
id: number;
url: string;
r: number;
size: number;
md5: string;
code: number;
expi: number;
fee: number;
mvFee: number;
st: number;
promotionVo: null | any;
msg: string;
}
+26
View File
@@ -0,0 +1,26 @@
export interface IPlayListSort {
code: number;
all: SortAll;
sub: SortAll[];
categories: SortCategories;
}
interface SortCategories {
'0': string;
'1': string;
'2': string;
'3': string;
'4': string;
}
interface SortAll {
name: string;
resourceCount?: number;
imgId?: number;
imgUrl?: any;
type?: number;
category?: number;
resourceType?: number;
hot?: boolean;
activity?: boolean;
}
+659
View File
@@ -0,0 +1,659 @@
export interface ISearchKeyword {
code: number;
message?: any;
data: SearchKeywordData;
}
interface SearchKeywordData {
showKeyword: string;
realkeyword: string;
searchType: number;
action: number;
alg: string;
gap: number;
source?: any;
bizQueryInfo: string;
}
export interface IHotSearch {
code: number;
data: Datum[];
message: string;
}
interface Datum {
searchWord: string;
score: number;
content: string;
source: number;
iconType: number;
iconUrl?: string;
url: string;
alg: string;
}
export interface ISearchDetail {
result: Result;
code: number;
}
interface Result {
song: Song2;
code: number;
mlog: Mlog2;
playList: PlayList2;
artist: Artist3;
album: Album3;
video: Video2;
sim_query: Simquery2;
djRadio: DjRadio2;
rec_type?: any;
talk: Talk2;
rec_query: null[];
user: User2;
order: string[];
}
interface User2 {
moreText: string;
more: boolean;
users: User[];
resourceIds: number[];
}
interface User {
defaultAvatar: boolean;
province: number;
authStatus: number;
followed: boolean;
avatarUrl: string;
accountStatus: number;
gender: number;
city: number;
birthday: number;
userId: number;
userType: number;
nickname: string;
signature: string;
description: string;
detailDescription: string;
avatarImgId: number;
backgroundImgId: number;
backgroundUrl: string;
authority: number;
mutual: boolean;
expertTags?: any;
experts?: any;
djStatus: number;
vipType: number;
remarkName?: any;
authenticationTypes: number;
avatarDetail?: any;
anchor: boolean;
avatarImgIdStr: string;
backgroundImgIdStr: string;
avatarImgId_str: string;
alg: string;
}
interface Talk2 {
more: boolean;
talks: Talk[];
resourceIds: number[];
}
interface Talk {
talkId: number;
shareUrl: string;
talkName: string;
shareCover: ShareCover;
showCover: ShareCover;
talkDes: string;
follows: number;
participations: number;
showParticipations: number;
status: number;
time?: any;
hasTag: boolean;
alg: string;
mlogCount: number;
commentCount: number;
}
interface ShareCover {
picKey: string;
nosKey: string;
width: number;
height: number;
url: string;
}
interface DjRadio2 {
moreText: string;
djRadios: DjRadio[];
more: boolean;
resourceIds: number[];
}
interface DjRadio {
id: number;
dj: Dj;
name: string;
picUrl: string;
desc: string;
subCount: number;
programCount: number;
createTime: number;
categoryId: number;
category: string;
radioFeeType: number;
feeScope: number;
buyed: boolean;
videos?: any;
finished: boolean;
underShelf: boolean;
purchaseCount: number;
price: number;
originalPrice: number;
discountPrice?: any;
lastProgramCreateTime: number;
lastProgramName?: any;
lastProgramId: number;
picId: number;
rcmdText?: string;
hightQuality: boolean;
whiteList: boolean;
liveInfo?: any;
playCount: number;
icon?: any;
composeVideo: boolean;
shareCount: number;
likedCount: number;
alg: string;
commentCount: number;
}
interface Dj {
defaultAvatar: boolean;
province: number;
authStatus: number;
followed: boolean;
avatarUrl: string;
accountStatus: number;
gender: number;
city: number;
birthday: number;
userId: number;
userType: number;
nickname: string;
signature: string;
description: string;
detailDescription: string;
avatarImgId: number;
backgroundImgId: number;
backgroundUrl: string;
authority: number;
mutual: boolean;
expertTags?: any;
experts?: any;
djStatus: number;
vipType: number;
remarkName?: any;
authenticationTypes: number;
avatarDetail?: any;
anchor: boolean;
avatarImgIdStr: string;
backgroundImgIdStr: string;
avatarImgId_str: string;
}
interface Simquery2 {
sim_querys: Simquery[];
more: boolean;
}
interface Simquery {
keyword: string;
alg: string;
}
interface Video2 {
moreText: string;
more: boolean;
videos: Video[];
resourceIds: number[];
}
interface Video {
coverUrl: string;
title: string;
durationms: number;
playTime: number;
type: number;
creator: Creator2[];
aliaName?: any;
transName?: any;
vid: string;
markTypes?: number[];
alg: string;
}
interface Creator2 {
userId: number;
userName: string;
}
interface Album3 {
moreText: string;
albums: Album2[];
more: boolean;
resourceIds: number[];
}
interface Album2 {
name: string;
id: number;
type: string;
size: number;
picId: number;
blurPicUrl: string;
companyId: number;
pic: number;
picUrl: string;
publishTime: number;
description: string;
tags: string;
company?: string;
briefDesc: string;
artist: Artist4;
songs?: any;
alias: string[];
status: number;
copyrightId: number;
commentThreadId: string;
artists: Artist5[];
paid: boolean;
onSale: boolean;
picId_str: string;
alg: string;
}
interface Artist5 {
name: string;
id: number;
picId: number;
img1v1Id: number;
briefDesc: string;
picUrl: string;
img1v1Url: string;
albumSize: number;
alias: any[];
trans: string;
musicSize: number;
topicPerson: number;
img1v1Id_str: string;
}
interface Artist4 {
name: string;
id: number;
picId: number;
img1v1Id: number;
briefDesc: string;
picUrl: string;
img1v1Url: string;
albumSize: number;
alias: string[];
trans: string;
musicSize: number;
topicPerson: number;
picId_str: string;
img1v1Id_str: string;
alia: string[];
}
interface Artist3 {
moreText: string;
artists: Artist2[];
more: boolean;
resourceIds: number[];
}
interface Artist2 {
id: number;
name: string;
picUrl: string;
alias: string[];
albumSize: number;
picId: number;
img1v1Url: string;
img1v1: number;
mvSize: number;
followed: boolean;
alg: string;
alia?: string[];
trans?: any;
accountId?: number;
}
interface PlayList2 {
moreText: string;
more: boolean;
playLists: PlayList[];
resourceIds: number[];
}
interface PlayList {
id: number;
name: string;
coverImgUrl: string;
creator: Creator;
subscribed: boolean;
trackCount: number;
userId: number;
playCount: number;
bookCount: number;
specialType: number;
officialTags: string[];
description: string;
highQuality: boolean;
track: Track;
alg: string;
}
interface Track {
name: string;
id: number;
position: number;
alias: any[];
status: number;
fee: number;
copyrightId: number;
disc: string;
no: number;
artists: Artist[];
album: Album;
starred: boolean;
popularity: number;
score: number;
starredNum: number;
duration: number;
playedNum: number;
dayPlays: number;
hearTime: number;
ringtone?: string;
crbt?: any;
audition?: any;
copyFrom: string;
commentThreadId: string;
rtUrl?: any;
ftype: number;
rtUrls: any[];
copyright: number;
mvid: number;
rtype: number;
rurl?: any;
hMusic: HMusic;
mMusic: HMusic;
lMusic: HMusic;
bMusic: HMusic;
mp3Url?: any;
transNames?: string[];
}
interface HMusic {
name?: any;
id: number;
size: number;
extension: string;
sr: number;
dfsId: number;
bitrate: number;
playTime: number;
volumeDelta: number;
}
interface Album {
name: string;
id: number;
type: string;
size: number;
picId: number;
blurPicUrl: string;
companyId: number;
pic: number;
picUrl: string;
publishTime: number;
description: string;
tags: string;
company?: string;
briefDesc: string;
artist: Artist;
songs: any[];
alias: any[];
status: number;
copyrightId: number;
commentThreadId: string;
artists: Artist[];
picId_str?: string;
}
interface Artist {
name: string;
id: number;
picId: number;
img1v1Id: number;
briefDesc: string;
picUrl: string;
img1v1Url: string;
albumSize: number;
alias: any[];
trans: string;
musicSize: number;
}
interface Creator {
nickname: string;
userId: number;
userType: number;
avatarUrl: string;
authStatus: number;
expertTags?: any;
experts?: any;
}
interface Mlog2 {
moreText: string;
more: boolean;
mlogs: Mlog[];
resourceIds: any[];
}
interface Mlog {
id: string;
type: number;
mlogBaseDataType: number;
position?: any;
resource: Resource;
alg: string;
reason?: any;
matchField: number;
matchFieldContent: string;
sameCity: boolean;
}
interface Resource {
mlogBaseData: MlogBaseData;
mlogExtVO: MlogExtVO;
userProfile: UserProfile;
status: number;
shareUrl: string;
}
interface UserProfile {
userId: number;
nickname: string;
avatarUrl: string;
followed: boolean;
userType: number;
isAnchor: boolean;
}
interface MlogExtVO {
likedCount: number;
commentCount: number;
playCount: number;
song?: any;
canCollect?: any;
artistName?: any;
rcmdInfo?: any;
strongPushMark?: any;
strongPushIcon?: any;
specialTag?: any;
channelTag: string;
artists: any[];
}
interface MlogBaseData {
id: string;
type: number;
text: string;
interveneText?: string;
pubTime: number;
coverUrl: string;
coverHeight: number;
coverWidth: number;
coverColor: number;
coverPicKey: string;
coverDynamicUrl?: any;
audio?: any;
threadId: string;
duration: number;
}
interface Song2 {
moreText: string;
songs: Song[];
more: boolean;
ksongInfos: KsongInfos;
resourceIds: number[];
}
interface KsongInfos {
'347230': _347230;
}
interface _347230 {
androidDownloadUrl: string;
accompanyId: string;
deeplinkUrl: string;
}
interface Song {
name: string;
id: number;
pst: number;
t: number;
ar: Ar[];
alia: any[];
pop: number;
st: number;
rt: string;
fee: number;
v: number;
crbt?: any;
cf: string;
al: Al;
dt: number;
h: H;
m: H;
l: H;
a?: any;
cd: string;
no: number;
rtUrl?: any;
ftype: number;
rtUrls: any[];
djId: number;
copyright: number;
s_id: number;
mark: number;
originCoverType: number;
originSongSimpleData?: any;
resourceState: boolean;
version: number;
single: number;
noCopyrightRcmd?: any;
rtype: number;
rurl?: any;
mst: number;
cp: number;
mv: number;
publishTime: number;
showRecommend: boolean;
recommendText: string;
tns?: string[];
officialTags: any[];
privilege: Privilege;
alg: string;
specialTags: any[];
}
interface Privilege {
id: number;
fee: number;
payed: number;
st: number;
pl: number;
dl: number;
sp: number;
cp: number;
subp: number;
cs: boolean;
maxbr: number;
fl: number;
toast: boolean;
flag: number;
preSell: boolean;
playMaxbr: number;
downloadMaxbr: number;
rscl?: any;
freeTrialPrivilege: FreeTrialPrivilege;
chargeInfoList: ChargeInfoList[];
}
interface ChargeInfoList {
rate: number;
chargeUrl?: any;
chargeMessage?: any;
chargeType: number;
}
interface FreeTrialPrivilege {
resConsumable: boolean;
userConsumable: boolean;
}
interface H {
br: number;
fid: number;
size: number;
vd: number;
}
interface Al {
id: number;
name: string;
picUrl: string;
tns: any[];
pic_str?: string;
pic: number;
}
interface Ar {
id: number;
name: string;
tns: any[];
alias: string[];
alia?: string[];
}
+32
View File
@@ -0,0 +1,32 @@
export interface IHotSinger {
code: number;
more: boolean;
artists: Artist[];
}
interface Artist {
name: string;
id: number;
picId: number;
img1v1Id: number;
briefDesc: string;
picUrl: string;
img1v1Url: string;
albumSize: number;
alias: string[];
trans: string;
musicSize: number;
topicPerson: number;
showPrivateMsg?: any;
isSubed?: any;
accountId?: number;
picId_str?: string;
img1v1Id_str: string;
transNames?: string[];
followed: boolean;
mvSize?: any;
publishTime?: any;
identifyTag?: any;
alg?: any;
fansCount?: any;
}
+91
View File
@@ -0,0 +1,91 @@
export interface IUserDetail {
level: number;
listenSongs: number;
userPoint: UserPoint;
mobileSign: boolean;
pcSign: boolean;
profile: Profile;
peopleCanSeeMyPlayRecord: boolean;
bindings: Binding[];
adValid: boolean;
code: number;
createTime: number;
createDays: number;
profileVillageInfo: ProfileVillageInfo;
}
interface ProfileVillageInfo {
title: string;
imageUrl?: any;
targetUrl: string;
}
interface Binding {
userId: number;
url: string;
expiresIn: number;
refreshTime: number;
bindingTime: number;
tokenJsonStr?: any;
expired: boolean;
id: number;
type: number;
}
interface Profile {
avatarDetail?: any;
userId: number;
avatarImgIdStr: string;
backgroundImgIdStr: string;
description: string;
vipType: number;
userType: number;
createTime: number;
nickname: string;
avatarUrl: string;
experts: Experts;
expertTags?: any;
djStatus: number;
accountStatus: number;
birthday: number;
gender: number;
province: number;
city: number;
defaultAvatar: boolean;
avatarImgId: number;
backgroundImgId: number;
backgroundUrl: string;
mutual: boolean;
followed: boolean;
remarkName?: any;
authStatus: number;
detailDescription: string;
signature: string;
authority: number;
followeds: number;
follows: number;
blacklist: boolean;
eventCount: number;
allSubscribedCount: number;
playlistBeSubscribedCount: number;
avatarImgId_str: string;
followTime?: any;
followMe: boolean;
artistIdentity: any[];
cCount: number;
sDJPCount: number;
playlistCount: number;
sCount: number;
newFollows: number;
}
interface Experts {}
interface UserPoint {
userId: number;
balance: number;
updateTime: number;
version: number;
status: number;
blockBalance: number;
}
+22
View File
@@ -0,0 +1,22 @@
export interface IElectronAPI {
minimize: () => void;
maximize: () => void;
close: () => void;
dragStart: (data: string) => void;
miniTray: () => void;
restart: () => void;
openLyric: () => void;
sendLyric: (data: string) => void;
unblockMusic: (id: number) => Promise<string>;
store: {
get: (key: string) => Promise<any>;
set: (key: string, value: any) => Promise<boolean>;
delete: (key: string) => Promise<boolean>;
};
}
declare global {
interface Window {
api: IElectronAPI;
}
}
+144
View File
@@ -0,0 +1,144 @@
import { computed } from 'vue';
import axios from 'axios';
import config from '../../../package.json';
import store from '@/store';
// 设置歌手背景图片
export const setBackgroundImg = (url: String) => {
return `background-image:url(${url})`;
};
// 设置动画类型
export const setAnimationClass = (type: String) => {
if (store.state.setData && store.state.setData.noAnimate) {
return '';
}
const speed = store.state.setData?.animationSpeed || 1;
let speedClass = '';
if (speed <= 0.3) speedClass = 'animate__slower';
else if (speed <= 0.8) speedClass = 'animate__slow';
else if (speed >= 2.5) speedClass = 'animate__faster';
else if (speed >= 1.5) speedClass = 'animate__fast';
return `animate__animated ${type}${speedClass ? ` ${speedClass}` : ''}`;
};
// 设置动画延时
export const setAnimationDelay = (index: number = 6, time: number = 50) => {
const speed = store.state.setData?.animationSpeed || 1;
return `animation-delay:${(index * time) / (speed * 2)}ms`;
};
// 将秒转换为分钟和秒
export const secondToMinute = (s: number) => {
if (!s) {
return '00:00';
}
const minute: number = Math.floor(s / 60);
const second: number = Math.floor(s % 60);
const minuteStr: string = minute > 9 ? minute.toString() : `0${minute.toString()}`;
const secondStr: string = second > 9 ? second.toString() : `0${second.toString()}`;
return `${minuteStr}:${secondStr}`;
};
// 格式化数字 千,万, 百万, 千万,亿
const units = [
{ value: 1e8, symbol: '亿' },
{ value: 1e4, symbol: '万' }
];
export const formatNumber = (num: string | number) => {
num = Number(num);
for (let i = 0; i < units.length; i++) {
if (num >= units[i].value) {
return `${(num / units[i].value).toFixed(1)}${units[i].symbol}`;
}
}
return num.toString();
};
const windowData = window as any;
export const getIsMc = () => {
if (!windowData.electron) {
return false;
}
const setData = window.electron.ipcRenderer.sendSync('get-store-value', 'set');
if (setData.isProxy) {
return true;
}
return false;
};
const ProxyUrl = import.meta.env.VITE_API_PROXY;
export const getMusicProxyUrl = (url: string) => {
if (!getIsMc()) {
return url;
}
const PUrl = url.split('').join('+');
return `${ProxyUrl}/mc?url=${PUrl}`;
};
export const getImgUrl = (url: string | undefined, size: string = '') => {
const bdUrl = 'https://image.baidu.com/search/down?url=';
const imgUrl = `${url}?param=${size}`;
if (!getIsMc()) {
return imgUrl;
}
return `${bdUrl}${encodeURIComponent(imgUrl)}`;
};
export const isMobile = computed(() => {
const flag = navigator.userAgent.match(
/(phone|pad|pod|iPhone|iPod|ios|iPad|Android|Mobile|BlackBerry|IEMobile|MQQBrowser|JUC|Fennec|wOSBrowser|BrowserNG|WebOS|Symbian|Windows Phone)/i
);
store.state.isMobile = !!flag;
// 给html标签 添加mobile
if (flag) document.documentElement.classList.add('mobile');
return !!flag;
});
export const isElectron = (window as any).electron !== undefined;
/**
* 检查版本更新
* @returns {Promise<{hasUpdate: boolean, latestVersion: string, currentVersion: string}>}
*/
export const checkUpdate = async () => {
try {
const response = await axios.get('https://api.github.com/repos/algerkong/AlgerMusicPlayer/releases/latest');
const latestVersion = response.data.tag_name.replace('v', '');
const currentVersion = config.version;
console.log(latestVersion, currentVersion);
// 版本号比较
const latest = latestVersion.split('.').map(Number);
const current = currentVersion.split('.').map(Number);
let hasUpdate = false;
for (let i = 0; i < 3; i++) {
if (latest[i] > current[i]) {
hasUpdate = true;
break;
} else if (latest[i] < current[i]) {
break;
}
}
return {
hasUpdate,
latestVersion,
currentVersion,
releaseInfo: response.data
};
} catch (error) {
console.error('检查更新失败:', error);
return {
hasUpdate: false,
latestVersion: '',
currentVersion: config.version,
releaseInfo: null
};
}
};
+277
View File
@@ -0,0 +1,277 @@
interface IColor {
backgroundColor: string;
primaryColor: string;
}
export const getImageLinearBackground = async (imageSrc: string): Promise<IColor> => {
try {
const primaryColor = await getImagePrimaryColor(imageSrc);
return {
backgroundColor: generateGradientBackground(primaryColor),
primaryColor
};
} catch (error) {
console.error('error', error);
return {
backgroundColor: '',
primaryColor: ''
};
}
};
export const getImageBackground = async (img: HTMLImageElement): Promise<IColor> => {
try {
const primaryColor = await getImageColor(img);
return {
backgroundColor: generateGradientBackground(primaryColor),
primaryColor
};
} catch (error) {
console.error('error', error);
return {
backgroundColor: '',
primaryColor: ''
};
}
};
const getImageColor = (img: HTMLImageElement): Promise<string> => {
return new Promise((resolve, reject) => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('无法获取canvas上下文'));
return;
}
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const color = getAverageColor(imageData.data);
resolve(`rgb(${color.join(',')})`);
});
};
const getImagePrimaryColor = (imageSrc: string): Promise<string> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'Anonymous';
img.src = imageSrc;
img.onload = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) {
reject(new Error('无法获取canvas上下文'));
return;
}
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const color = getAverageColor(imageData.data);
resolve(`rgb(${color.join(',')})`);
};
img.onerror = () => reject(new Error('图片加载失败'));
});
};
const getAverageColor = (data: Uint8ClampedArray): number[] => {
let r = 0;
let g = 0;
let b = 0;
let count = 0;
for (let i = 0; i < data.length; i += 4) {
r += data[i];
g += data[i + 1];
b += data[i + 2];
count++;
}
return [Math.round(r / count), Math.round(g / count), Math.round(b / count)];
};
const generateGradientBackground = (color: string): string => {
const [r, g, b] = color.match(/\d+/g)?.map(Number) || [0, 0, 0];
const [h, s, l] = rgbToHsl(r, g, b);
// 增加亮度和暗度的差异
const lightL = Math.min(l + 0.2, 0.95);
const darkL = Math.max(l - 0.3, 0.05);
const midL = (lightL + darkL) / 2;
// 调整饱和度以增强效果
const lightS = Math.min(s * 0.8, 1);
const darkS = Math.min(s * 1.2, 1);
const [lightR, lightG, lightB] = hslToRgb(h, lightS, lightL);
const [midR, midG, midB] = hslToRgb(h, s, midL);
const [darkR, darkG, darkB] = hslToRgb(h, darkS, darkL);
const lightColor = `rgb(${lightR}, ${lightG}, ${lightB})`;
const midColor = `rgb(${midR}, ${midG}, ${midB})`;
const darkColor = `rgb(${darkR}, ${darkG}, ${darkB})`;
// 使用三个颜色点创建更丰富的渐变
return `linear-gradient(to bottom, ${lightColor} 0%, ${midColor} 50%, ${darkColor} 100%)`;
};
// Helper functions (unchanged)
function rgbToHsl(r: number, g: number, b: number): [number, number, number] {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h = 0;
let s;
const l = (max + min) / 2;
if (max === min) {
h = s = 0;
} else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
break;
default:
break;
}
h /= 6;
}
return [h, s, l];
}
function hslToRgb(h: number, s: number, l: number): [number, number, number] {
let r;
let g;
let b;
if (s === 0) {
r = g = b = l;
} else {
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)];
}
// 添加新的接口
interface ITextColors {
primary: string;
active: string;
theme: string;
}
// 添加新的函数
export const calculateBrightness = (r: number, g: number, b: number): number => {
return (0.299 * r + 0.587 * g + 0.114 * b) / 255;
};
export const parseGradient = (gradientStr: string) => {
const matches = gradientStr.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/g);
if (!matches) return [];
return matches.map((rgb) => {
const [r, g, b] = rgb.match(/\d+/g)!.map(Number);
return { r, g, b };
});
};
export const interpolateRGB = (start: number, end: number, progress: number) => {
return Math.round(start + (end - start) * progress);
};
export const createGradientString = (
colors: { r: number; g: number; b: number }[],
percentages = [0, 50, 100]
) => {
return `linear-gradient(to bottom, ${colors
.map((color, i) => `rgb(${color.r}, ${color.g}, ${color.b}) ${percentages[i]}%`)
.join(', ')})`;
};
export const getTextColors = (gradient: string = ''): ITextColors => {
const defaultColors = {
primary: 'rgba(255, 255, 255, 0.54)',
active: '#ffffff',
theme: 'light'
};
if (!gradient) return defaultColors;
const colors = parseGradient(gradient);
if (!colors.length) return defaultColors;
const mainColor = colors[1] || colors[0];
const brightness = calculateBrightness(mainColor.r, mainColor.g, mainColor.b);
const isDark = brightness > 0.6;
return {
primary: isDark ? 'rgba(0, 0, 0, 0.54)' : 'rgba(255, 255, 255, 0.54)',
active: isDark ? '#000000' : '#ffffff',
theme: isDark ? 'dark' : 'light'
};
};
export const getHoverBackgroundColor = (isDark: boolean): string => {
return isDark ? 'rgba(0, 0, 0, 0.08)' : 'rgba(255, 255, 255, 0.08)';
};
export const animateGradient = (
oldGradient: string,
newGradient: string,
onUpdate: (gradient: string) => void,
duration = 1000
) => {
const startColors = parseGradient(oldGradient);
const endColors = parseGradient(newGradient);
if (startColors.length !== endColors.length) return null;
const startTime = performance.now();
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime;
const progress = Math.min(elapsed / duration, 1);
const currentColors = startColors.map((startColor, i) => ({
r: interpolateRGB(startColor.r, endColors[i].r, progress),
g: interpolateRGB(startColor.g, endColors[i].g, progress),
b: interpolateRGB(startColor.b, endColors[i].b, progress)
}));
onUpdate(createGradientString(currentColors));
if (progress < 1) {
return requestAnimationFrame(animate);
}
return null;
};
return requestAnimationFrame(animate);
};
+77
View File
@@ -0,0 +1,77 @@
import axios, { InternalAxiosRequestConfig } from 'axios';
const setData = window.electron.ipcRenderer.sendSync('get-store-value', 'set')
// 扩展请求配置接口
interface CustomAxiosRequestConfig extends InternalAxiosRequestConfig {
retryCount?: number;
}
const baseURL = window.electron ? `http://127.0.0.1:${setData.musicApiPort}` : import.meta.env.VITE_API;
const request = axios.create({
baseURL,
timeout: 5000
});
// 最大重试次数
const MAX_RETRIES = 3;
// 重试延迟(毫秒)
const RETRY_DELAY = 500;
// 请求拦截器
request.interceptors.request.use(
(config: CustomAxiosRequestConfig) => {
// 初始化重试次数
config.retryCount = 0;
// 在请求发送之前做一些处理
// 在get请求params中添加timestamp
if (config.method === 'get') {
config.params = {
...config.params,
timestamp: Date.now()
};
const token = localStorage.getItem('token');
if (token) {
config.params.cookie = token;
}
}
return config;
},
(error) => {
// 当请求异常时做一些处理
return Promise.reject(error);
}
);
// 响应拦截器
request.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
const config = error.config as CustomAxiosRequestConfig;
// 如果没有配置重试次数,则初始化为0
if (!config || !config.retryCount) {
config.retryCount = 0;
}
// 检查是否还可以重试
if (config.retryCount < MAX_RETRIES) {
config.retryCount++;
// 延迟重试
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY));
// 重新发起请求
return request(config);
}
return Promise.reject(error);
}
);
export default request;
+20
View File
@@ -0,0 +1,20 @@
import axios from 'axios';
const baseURL = `${import.meta.env.VITE_API_MUSIC}`;
const request = axios.create({
baseURL,
timeout: 10000
});
// 请求拦截器
request.interceptors.request.use(
(config) => {
return config;
},
(error) => {
// 当请求异常时做一些处理
return Promise.reject(error);
}
);
export default request;
+19
View File
@@ -0,0 +1,19 @@
export type ThemeType = 'dark' | 'light';
// 应用主题
export const applyTheme = (theme: ThemeType) => {
// 使用 Tailwind 的暗色主题类
if (theme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
// 保存主题到本地存储
localStorage.setItem('theme', theme);
};
// 获取当前主题
export const getCurrentTheme = (): ThemeType => {
return (localStorage.getItem('theme') as ThemeType) || 'light';
};
+217
View File
@@ -0,0 +1,217 @@
<template>
<div v-if="isComponent ? favoriteSongs.length : true" class="favorite-page">
<div class="favorite-header" :class="setAnimationClass('animate__fadeInLeft')">
<h2>我的收藏</h2>
<div class="favorite-count"> {{ favoriteList.length }} </div>
</div>
<div class="favorite-main" :class="setAnimationClass('animate__bounceInRight')">
<n-scrollbar ref="scrollbarRef" class="favorite-content" @scroll="handleScroll">
<div v-if="favoriteList.length === 0" class="empty-tip">
<n-empty description="还没有收藏歌曲" />
</div>
<div v-else class="favorite-list">
<song-item
v-for="(song, index) in favoriteSongs"
:key="song.id"
:item="song"
:favorite="!isComponent"
:class="setAnimationClass('animate__bounceInLeft')"
:style="getItemAnimationDelay(index)"
@play="handlePlay"
/>
<div v-if="isComponent" class="favorite-list-more text-center">
<n-button text type="primary" @click="handleMore">查看更多</n-button>
</div>
<div v-if="loading" class="loading-wrapper">
<n-spin size="large" />
</div>
<div v-if="noMore" class="no-more-tip">没有更多了</div>
</div>
</n-scrollbar>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { getMusicDetail } from '@/api/music';
import SongItem from '@/components/common/SongItem.vue';
import type { SongResult } from '@/type/music';
import { setAnimationClass, setAnimationDelay } from '@/utils';
const store = useStore();
const favoriteList = computed(() => store.state.favoriteList);
const favoriteSongs = ref<SongResult[]>([]);
const loading = ref(false);
const noMore = ref(false);
const scrollbarRef = ref();
//
const pageSize = 16;
const currentPage = ref(1);
const props = defineProps({
isComponent: {
type: Boolean,
default: false
}
});
// ID
const getCurrentPageIds = () => {
const reversedList = [...favoriteList.value];
const startIndex = (currentPage.value - 1) * pageSize;
const endIndex = startIndex + pageSize;
return reversedList.slice(startIndex, endIndex);
};
//
const getFavoriteSongs = async () => {
if (favoriteList.value.length === 0) {
favoriteSongs.value = [];
return;
}
if (props.isComponent && favoriteSongs.value.length >= 16) {
return;
}
loading.value = true;
try {
const currentIds = getCurrentPageIds();
const res = await getMusicDetail(currentIds);
if (res.data.songs) {
const newSongs = res.data.songs.map((song: SongResult) => ({
...song,
picUrl: song.al?.picUrl || ''
}));
//
if (currentPage.value === 1) {
favoriteSongs.value = newSongs;
} else {
favoriteSongs.value = [...favoriteSongs.value, ...newSongs];
}
//
noMore.value = favoriteSongs.value.length >= favoriteList.value.length;
}
} catch (error) {
console.error('获取收藏歌曲失败:', error);
} finally {
loading.value = false;
}
};
//
const handleScroll = (e: any) => {
const { scrollTop, scrollHeight, offsetHeight } = e.target;
const threshold = 100; //
if (!loading.value && !noMore.value && scrollHeight - (scrollTop + offsetHeight) < threshold) {
currentPage.value++;
getFavoriteSongs();
}
};
onMounted(() => {
getFavoriteSongs();
});
//
watch(
favoriteList,
() => {
currentPage.value = 1;
noMore.value = false;
getFavoriteSongs();
},
{ deep: true, immediate: true }
);
const handlePlay = () => {
store.commit('setPlayList', favoriteSongs.value);
};
const getItemAnimationDelay = (index: number) => {
return setAnimationDelay(index, 30);
};
const router = useRouter();
const handleMore = () => {
router.push('/favorite');
};
</script>
<style lang="scss" scoped>
.favorite-page {
@apply h-full flex flex-col pt-2;
@apply bg-light dark:bg-black;
.favorite-header {
@apply flex items-center justify-between flex-shrink-0 px-4;
h2 {
@apply text-xl font-bold pb-2;
@apply text-gray-900 dark:text-white;
}
.favorite-count {
@apply text-gray-500 dark:text-gray-400 text-sm;
}
}
.favorite-main {
@apply flex flex-col flex-grow min-h-0;
.favorite-content {
@apply flex-grow min-h-0;
.empty-tip {
@apply h-full flex items-center justify-center;
@apply text-gray-500 dark:text-gray-400;
}
.favorite-list {
@apply space-y-2 pb-4 px-4;
&-more {
@apply mt-4;
.n-button {
@apply text-green-500 hover:text-green-600;
}
}
}
}
}
}
.loading-wrapper {
@apply flex justify-center items-center py-20;
}
.no-more-tip {
@apply text-center py-4 text-sm;
@apply text-gray-500 dark:text-gray-400;
}
.mobile {
.favorite-page {
@apply p-4;
.favorite-header {
@apply mb-4;
h2 {
@apply text-xl;
}
}
}
}
</style>
+165
View File
@@ -0,0 +1,165 @@
<template>
<div class="history-page">
<div class="title" :class="setAnimationClass('animate__fadeInRight')">播放历史</div>
<n-scrollbar ref="scrollbarRef" :size="100" @scroll="handleScroll">
<div class="history-list-content" :class="setAnimationClass('animate__bounceInLeft')">
<div
v-for="(item, index) in displayList"
:key="item.id"
class="history-item"
:class="setAnimationClass('animate__bounceInRight')"
:style="setAnimationDelay(index, 30)"
>
<song-item class="history-item-content" :item="item" @play="handlePlay" />
<div class="history-item-count min-w-[60px]">
{{ item.count }}
</div>
<div class="history-item-delete">
<i class="iconfont icon-close" @click="handleDelMusic(item)"></i>
</div>
</div>
<div v-if="loading" class="loading-wrapper">
<n-spin size="large" />
</div>
<div v-if="noMore" class="no-more-tip">没有更多了</div>
</div>
</n-scrollbar>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useStore } from 'vuex';
import { getMusicDetail } from '@/api/music';
import { useMusicHistory } from '@/hooks/MusicHistoryHook';
import type { SongResult } from '@/type/music';
import { setAnimationClass, setAnimationDelay } from '@/utils';
import SongItem from '@/components/common/SongItem.vue';
defineOptions({
name: 'History'
});
const store = useStore();
const { delMusic, musicList } = useMusicHistory();
const scrollbarRef = ref();
const loading = ref(false);
const noMore = ref(false);
const displayList = ref<SongResult[]>([]);
//
const pageSize = 20;
const currentPage = ref(1);
//
const getHistorySongs = async () => {
if (musicList.value.length === 0) {
displayList.value = [];
return;
}
loading.value = true;
try {
const startIndex = (currentPage.value - 1) * pageSize;
const endIndex = startIndex + pageSize;
const currentPageItems = musicList.value.slice(startIndex, endIndex);
const currentIds = currentPageItems.map((item) => item.id);
const res = await getMusicDetail(currentIds);
if (res.data.songs) {
const newSongs = res.data.songs.map((song: SongResult) => {
const historyItem = currentPageItems.find((item) => item.id === song.id);
return {
...song,
picUrl: song.al?.picUrl || '',
count: historyItem?.count || 0
};
});
if (currentPage.value === 1) {
displayList.value = newSongs;
} else {
displayList.value = [...displayList.value, ...newSongs];
}
noMore.value = displayList.value.length >= musicList.value.length;
}
} catch (error) {
console.error('获取历史记录失败:', error);
} finally {
loading.value = false;
}
};
//
const handleScroll = (e: any) => {
const { scrollTop, scrollHeight, offsetHeight } = e.target;
const threshold = 100; //
if (!loading.value && !noMore.value && scrollHeight - (scrollTop + offsetHeight) < threshold) {
currentPage.value++;
getHistorySongs();
}
};
//
const handlePlay = () => {
store.commit('setPlayList', displayList.value);
};
onMounted(() => {
getHistorySongs();
});
// displayList
const handleDelMusic = async (item: SongResult) => {
delMusic(item);
musicList.value = musicList.value.filter((music) => music.id !== item.id);
displayList.value = displayList.value.filter((music) => music.id !== item.id);
};
</script>
<style scoped lang="scss">
.history-page {
@apply h-full w-full pt-2;
@apply bg-light dark:bg-black;
.title {
@apply pl-4 text-xl font-bold pb-2 px-4;
@apply text-gray-900 dark:text-white;
}
.history-list-content {
@apply mt-2 pb-28 px-4;
.history-item {
@apply flex items-center justify-between;
&-content {
@apply flex-1;
}
&-count {
@apply px-4 text-lg text-center;
@apply text-gray-600 dark:text-gray-400;
}
&-delete {
@apply cursor-pointer rounded-full border-2 w-8 h-8 flex justify-center items-center;
@apply border-gray-400 dark:border-gray-600;
@apply text-gray-600 dark:text-gray-400;
@apply hover:border-red-500 hover:text-red-500;
}
}
}
}
.loading-wrapper {
@apply flex justify-center items-center py-8;
}
.no-more-tip {
@apply text-center py-4 text-sm;
@apply text-gray-500 dark:text-gray-400;
}
</style>
@@ -0,0 +1,17 @@
<template>
<div class="flex gap-4 h-full pb-4">
<favorite class="flex-item" />
<history class="flex-item" />
</div>
</template>
<script setup lang="ts">
import Favorite from '@/views/favorite/index.vue';
import History from '@/views/history/index.vue';
</script>
<style scoped>
.flex-item {
@apply flex-1 bg-light-100 dark:bg-dark-100 rounded-2xl overflow-hidden;
}
</style>
+63
View File
@@ -0,0 +1,63 @@
<template>
<n-scrollbar :size="100" :x-scrollable="false">
<div class="main-page">
<!-- 推荐歌手 -->
<recommend-singer />
<div class="main-content">
<!-- 歌单分类列表 -->
<playlist-type v-if="!isMobile" />
<!-- 本周最热音乐 -->
<recommend-songlist />
<!-- 推荐最新专辑 -->
<div>
<favorite-list is-component />
<recommend-album />
</div>
</div>
</div>
</n-scrollbar>
</template>
<script lang="ts" setup>
import PlaylistType from '@/components/PlaylistType.vue';
import RecommendAlbum from '@/components/RecommendAlbum.vue';
import RecommendSinger from '@/components/RecommendSinger.vue';
import RecommendSonglist from '@/components/RecommendSonglist.vue';
import { isMobile } from '@/utils';
import FavoriteList from '@/views/favorite/index.vue';
defineOptions({
name: 'Home'
});
</script>
<style lang="scss" scoped>
.main-page {
@apply h-full w-full overflow-hidden bg-light dark:bg-black;
}
.main-content {
@apply mt-6 flex mb-28;
}
.mobile {
.main-content {
@apply flex-col mx-4;
}
:deep(.favorite-page) {
@apply p-0 mx-4 h-full;
}
}
:deep(.favorite-page) {
@apply p-0 mx-4 h-[300px];
.favorite-header {
@apply mb-0 px-0 !important;
h2 {
@apply text-lg font-bold text-gray-900 dark:text-white;
}
}
.favorite-list {
@apply px-0 !important;
}
}
</style>
+329
View File
@@ -0,0 +1,329 @@
<template>
<div class="list-page">
<!-- 修改歌单分类部分 -->
<div class="play-list-type">
<n-scrollbar ref="scrollbarRef" x-scrollable>
<div class="categories-wrapper" @wheel.prevent="handleWheel">
<span
v-for="(item, index) in playlistCategory?.sub"
:key="item.name"
class="play-list-type-item"
:class="[setAnimationClass('animate__bounceIn'), { active: currentType === item.name }]"
:style="getAnimationDelay(index)"
@click="handleClickPlaylistType(item.name)"
>
{{ item.name }}
</span>
</div>
</n-scrollbar>
</div>
<!-- 歌单列表 -->
<n-scrollbar class="recommend" :size="100" @scroll="handleScroll">
<div v-loading="loading" class="recommend-list">
<div
v-for="(item, index) in recommendList"
:key="item.id"
class="recommend-item"
:class="setAnimationClass('animate__bounceIn')"
:style="getItemAnimationDelay(index)"
@click.stop="selectRecommendItem(item)"
>
<div class="recommend-item-img">
<n-image
class="recommend-item-img-img"
:src="getImgUrl(item.picUrl || item.coverImgUrl, '200y200')"
width="200"
height="200"
lazy
preview-disabled
/>
<div class="top">
<div class="play-count">{{ formatNumber(item.playCount) }}</div>
<i class="iconfont icon-videofill"></i>
</div>
</div>
<div class="recommend-item-title">{{ item.name }}</div>
</div>
</div>
<!-- 加载状态 -->
<div v-if="isLoadingMore" class="loading-more">
<n-spin size="small" />
<span class="ml-2">加载中...</span>
</div>
<div v-if="!hasMore && recommendList.length > 0" class="no-more">没有更多了</div>
</n-scrollbar>
<music-list
v-model:show="showMusic"
v-model:loading="listLoading"
:name="recommendItem?.name || ''"
:song-list="listDetail?.playlist.tracks || []"
:list-info="listDetail?.playlist"
/>
</div>
</template>
<script lang="ts" setup>
import { useRoute } from 'vue-router';
import { getPlaylistCategory } from '@/api/home';
import { getListByCat, getListDetail } from '@/api/list';
import MusicList from '@/components/MusicList.vue';
import type { IRecommendItem } from '@/type/list';
import type { IListDetail } from '@/type/listDetail';
import type { IPlayListSort } from '@/type/playlist';
import { formatNumber, getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils';
defineOptions({
name: 'List'
});
const TOTAL_ITEMS = 42; //
const recommendList = ref<any[]>([]);
const showMusic = ref(false);
const page = ref(0);
const hasMore = ref(true);
const isLoadingMore = ref(false);
//
const getItemAnimationDelay = (index: number) => {
const currentPageIndex = index % TOTAL_ITEMS;
return setAnimationDelay(currentPageIndex, 30);
};
const recommendItem = ref<IRecommendItem | null>();
const listDetail = ref<IListDetail | null>();
const listLoading = ref(true);
const selectRecommendItem = async (item: IRecommendItem) => {
listLoading.value = true;
recommendItem.value = null;
listDetail.value = null;
showMusic.value = true;
recommendItem.value = item;
const { data } = await getListDetail(item.id);
listDetail.value = data;
listLoading.value = false;
};
const route = useRoute();
const listTitle = ref(route.query.type || '歌单列表');
const loading = ref(false);
const loadList = async (type: string, isLoadMore = false) => {
if (!hasMore.value && isLoadMore) return;
if (isLoadMore) {
isLoadingMore.value = true;
} else {
loading.value = true;
page.value = 0;
recommendList.value = [];
}
try {
const params = {
cat: type === '每日推荐' ? '' : type,
limit: TOTAL_ITEMS,
offset: page.value * TOTAL_ITEMS
};
const { data } = await getListByCat(params);
if (isLoadMore) {
recommendList.value.push(...data.playlists);
} else {
recommendList.value = data.playlists;
}
hasMore.value = data.more;
page.value++;
} catch (error) {
console.error('加载歌单列表失败:', error);
} finally {
loading.value = false;
isLoadingMore.value = false;
}
};
//
const handleScroll = (e: any) => {
const { scrollTop, scrollHeight, clientHeight } = e.target;
// 100px
if (scrollTop + clientHeight >= scrollHeight - 100 && !isLoadingMore.value && hasMore.value) {
loadList(route.query.type as string, true);
}
};
//
const playlistCategory = ref<IPlayListSort>();
const currentType = ref((route.query.type as string) || '每日推荐');
const getAnimationDelay = computed(() => {
return (index: number) => setAnimationDelay(index, 30);
});
//
const loadPlaylistCategory = async () => {
const { data } = await getPlaylistCategory();
playlistCategory.value = {
...data,
sub: [
{
name: '每日推荐',
category: 0
},
...data.sub
]
};
};
const handleClickPlaylistType = (type: string) => {
currentType.value = type;
listTitle.value = type;
loading.value = true;
loadList(type);
};
const scrollbarRef = ref();
const handleWheel = (e: WheelEvent) => {
const scrollbar = scrollbarRef.value;
if (scrollbar) {
const delta = e.deltaY || e.detail;
scrollbar.scrollBy({ left: delta });
}
};
onMounted(() => {
loadPlaylistCategory(); //
currentType.value = (route.query.type as string) || currentType.value;
loadList(currentType.value);
});
watch(
() => route.query,
async (newParams) => {
if (newParams.type) {
recommendList.value = [];
listTitle.value = newParams.type || '歌单列表';
currentType.value = newParams.type as string;
loading.value = true;
loadList(newParams.type as string);
}
}
);
</script>
<style lang="scss" scoped>
.list-page {
@apply relative h-full w-full;
@apply bg-light dark:bg-black;
}
.recommend {
@apply w-full h-full;
&-title {
@apply text-lg font-bold pb-2;
@apply text-gray-900 dark:text-white;
}
&-list {
@apply grid gap-x-8 gap-y-6 pb-28 pr-4;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
&-item {
@apply flex flex-col;
&-img {
@apply rounded-xl overflow-hidden relative w-full aspect-square;
&-img {
@apply block w-full h-full;
}
img {
@apply absolute top-0 left-0 w-full h-full object-cover rounded-xl;
}
&:hover img {
@apply hover:scale-110 transition-all duration-300 ease-in-out;
}
.top {
@apply absolute w-full h-full top-0 left-0 flex justify-center items-center transition-all duration-300 ease-in-out cursor-pointer;
@apply bg-black bg-opacity-50;
opacity: 0;
i {
@apply text-5xl text-white transition-all duration-500 ease-in-out opacity-0;
}
&:hover {
@apply opacity-100;
}
&:hover i {
@apply transform scale-150 opacity-100;
}
.play-count {
@apply absolute top-2 left-2 text-sm text-white;
}
}
}
&-title {
@apply mt-2 text-sm line-clamp-1;
@apply text-gray-900 dark:text-white;
}
}
}
.loading-more {
@apply flex justify-center items-center py-4;
@apply text-gray-500 dark:text-gray-400;
}
.no-more {
@apply text-center py-4;
@apply text-gray-500 dark:text-gray-400;
}
.mobile {
.recommend-title {
@apply text-xl font-bold px-4;
}
.recommend-list {
@apply px-4 gap-4;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
}
}
//
.play-list-type {
.categories-wrapper {
@apply flex items-center py-2;
white-space: nowrap;
}
&-item {
@apply py-2 px-3 mr-3 inline-block rounded-xl cursor-pointer transition-all duration-300;
@apply bg-light dark:bg-black text-gray-900 dark:text-white;
@apply border border-gray-200 dark:border-gray-700;
&:hover {
@apply bg-green-50 dark:bg-green-900;
}
&.active {
@apply bg-green-500 border-green-500 text-white;
}
}
}
.mobile {
.play-list-type {
@apply mx-0 w-full;
}
}
</style>
+208
View File
@@ -0,0 +1,208 @@
<script lang="ts" setup>
import { useMessage } from 'naive-ui';
import { onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { checkQr, createQr, getQrKey, getUserDetail, loginByCellphone } from '@/api/login';
import { setAnimationClass } from '@/utils';
defineOptions({
name: 'Login'
});
const message = useMessage();
const store = useStore();
const router = useRouter();
const isQr = ref(false);
const qrUrl = ref<string>();
onMounted(() => {
loadLogin();
});
const timerRef = ref(null);
const loadLogin = async () => {
try {
if (timerRef.value) {
clearInterval(timerRef.value);
timerRef.value = null;
}
if (!isQr.value) return;
const qrKey = await getQrKey();
const key = qrKey.data.data.unikey;
const { data } = await createQr(key);
qrUrl.value = data.data.qrimg;
const timer = timerIsQr(key);
// 便
timerRef.value = timer as any;
} catch (error) {
console.error('加载登录信息时出错:', error);
}
};
// 使 ref 便
const timerIsQr = (key: string) => {
const timer = setInterval(async () => {
try {
const { data } = await checkQr(key);
if (data.code === 800) {
clearInterval(timer);
timerRef.value = null;
}
if (data.code === 803) {
localStorage.setItem('token', data.cookie);
const user = await getUserDetail();
store.state.user = user.data.profile;
localStorage.setItem('user', JSON.stringify(store.state.user));
message.success('登录成功');
clearInterval(timer);
timerRef.value = null;
router.push('/user');
}
} catch (error) {
console.error('检查二维码状态时出错:', error);
//
clearInterval(timer);
timerRef.value = null;
}
}, 3000);
return timer;
};
//
onBeforeUnmount(() => {
if (timerRef.value) {
clearInterval(timerRef.value);
timerRef.value = null;
}
});
//
const chooseQr = () => {
isQr.value = !isQr.value;
loadLogin();
};
//
const phone = ref('');
const password = ref('');
const loginPhone = async () => {
const { data } = await loginByCellphone(phone.value, password.value);
if (data.code === 200) {
message.success('登录成功');
store.state.user = data.profile;
localStorage.setItem('token', data.cookie);
setTimeout(() => {
router.push('/user');
}, 1000);
}
};
</script>
<template>
<div class="login-page">
<div class="phone-login">
<div class="bg"></div>
<div class="content">
<div v-if="isQr" class="phone" :class="setAnimationClass('animate__fadeInUp')">
<div class="login-title">扫码登陆</div>
<img class="qr-img" :src="qrUrl" />
<div class="text">使用网易云APP扫码登录</div>
</div>
<div v-else class="phone" :class="setAnimationClass('animate__fadeInUp')">
<div class="login-title">手机号登录</div>
<div class="phone-page">
<input v-model="phone" class="phone-input" type="text" placeholder="手机号" />
<input v-model="password" class="phone-input" type="password" placeholder="密码" />
</div>
<div class="text">使用网易云账号登录</div>
<n-button class="btn-login" @click="loginPhone()">登录</n-button>
</div>
</div>
<div class="bottom">
<div class="title" @click="chooseQr()">{{ isQr ? '手机号登录' : '扫码登录' }}</div>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.login-page {
@apply flex flex-col items-center justify-center p-20 pt-20;
@apply bg-light dark:bg-black;
}
.login-title {
@apply text-2xl font-bold mb-6 text-white;
}
.text {
@apply mt-4 text-white text-xs;
}
.phone-login {
width: 350px;
height: 550px;
@apply rounded-2xl rounded-b-none bg-cover bg-no-repeat relative overflow-hidden;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' version='1.1' xmlns:xlink='http://www.w3.org/1999/xlink' xmlns:svgjs='http://svgjs.dev/svgjs' width='400' height='560' preserveAspectRatio='none' viewBox='0 0 400 560'%3e%3cg mask='url(%26quot%3b%23SvgjsMask1066%26quot%3b)' fill='none'%3e%3crect width='400' height='560' x='0' y='0' fill='rgba(24%2c 106%2c 59%2c 1)'%3e%3c/rect%3e%3cpath d='M0%2c234.738C43.535%2c236.921%2c80.103%2c205.252%2c116.272%2c180.923C151.738%2c157.067%2c188.295%2c132.929%2c207.855%2c94.924C227.898%2c55.979%2c233.386%2c10.682%2c226.119%2c-32.511C218.952%2c-75.107%2c199.189%2c-115.793%2c167.469%2c-145.113C137.399%2c-172.909%2c92.499%2c-171.842%2c55.779%2c-189.967C8.719%2c-213.196%2c-28.344%2c-282.721%2c-78.217%2c-266.382C-128.725%2c-249.834%2c-111.35%2c-166.696%2c-143.781%2c-124.587C-173.232%2c-86.348%2c-244.72%2c-83.812%2c-255.129%2c-36.682C-265.368%2c9.678%2c-217.952%2c48.26%2c-190.512%2c87.004C-167.691%2c119.226%2c-140.216%2c145.431%2c-109.013%2c169.627C-74.874%2c196.1%2c-43.147%2c232.575%2c0%2c234.738' fill='%23114b2a'%3e%3c/path%3e%3cpath d='M400 800.9010000000001C443.973 795.023 480.102 765.6 513.011 735.848 541.923 709.71 561.585 676.6320000000001 577.037 640.85 592.211 605.712 606.958 568.912 601.458 531.035 595.962 493.182 568.394 464.36400000000003 546.825 432.775 522.317 396.88300000000004 507.656 347.475 466.528 333.426 425.366 319.366 384.338 352.414 342.111 362.847 297.497 373.869 242.385 362.645 211.294 396.486 180.212 430.318 192.333 483.83299999999997 188.872 529.644 185.656 572.218 178.696 614.453 191.757 655.101 205.885 699.068 227.92 742.4110000000001 265.75 768.898 304.214 795.829 353.459 807.1220000000001 400 800.9010000000001' fill='%231f894c'%3e%3c/path%3e%3c/g%3e%3cdefs%3e%3cmask id='SvgjsMask1066'%3e%3crect width='400' height='560' fill='white'%3e%3c/rect%3e%3c/mask%3e%3c/defs%3e%3c/svg%3e");
box-shadow: inset 0px 0px 20px 5px rgba(0, 0, 0, 0.37);
.bg {
@apply absolute w-full h-full bg-light-100 dark:bg-dark-100 opacity-20;
}
.bottom {
width: 200%;
height: 250px;
bottom: -180px;
border-radius: 50%;
left: 50%;
padding: 10px;
transform: translateX(-50%);
@apply absolute bg-light dark:bg-dark flex justify-center text-lg font-bold cursor-pointer;
@apply text-gray-400 hover:text-gray-800 hover:dark:text-white transition-colors;
box-shadow: 10px 0px 20px rgba(0, 0, 0, 0.66);
}
.content {
@apply absolute w-full h-full p-4 flex flex-col items-center justify-center pb-20 text-center;
.qr-img {
@apply rounded-2xl cursor-pointer transition-opacity;
}
.phone {
animation-duration: 0.5s;
&-page {
@apply bg-light dark:bg-gray-800 bg-opacity-90 dark:bg-opacity-90;
width: 250px;
@apply rounded-2xl overflow-hidden;
}
&-input {
height: 40px;
@apply w-full px-4 outline-none;
@apply text-gray-900 dark:text-white bg-transparent;
@apply border-b border-gray-200 dark:border-gray-700;
@apply placeholder-gray-500 dark:placeholder-gray-400;
&:focus {
@apply border-green-500;
}
}
}
.btn-login {
width: 250px;
height: 40px;
@apply mt-10 text-white rounded-xl;
@apply bg-green-600 hover:bg-green-700 transition-colors;
}
}
}
</style>
+803
View File
@@ -0,0 +1,803 @@
<template>
<div
class="lyric-window"
:class="[lyricSetting.theme, { lyric_lock: lyricSetting.isLock }]"
@mousedown="handleMouseDown"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<div class="drag-overlay"></div>
<!-- 顶部控制栏 -->
<div class="control-bar" :class="{ 'control-bar-show': showControls }">
<div class="font-size-controls">
<n-button-group>
<n-button quaternary size="small" :disabled="fontSize <= 12" @click="decreaseFontSize">
<i class="ri-subtract-line"></i>
</n-button>
<n-button quaternary size="small" :disabled="fontSize >= 48" @click="increaseFontSize">
<i class="ri-add-line"></i>
</n-button>
</n-button-group>
<div>{{ staticData.playMusic.name }}</div>
</div>
<!-- 添加播放控制按钮 -->
<div class="play-controls">
<div class="control-button" @click="handlePrev">
<i class="ri-skip-back-fill"></i>
</div>
<div class="control-button play-button" @click="handlePlayPause">
<i :class="dynamicData.isPlay ? 'ri-pause-fill' : 'ri-play-fill'"></i>
</div>
<div class="control-button" @click="handleNext">
<i class="ri-skip-forward-fill"></i>
</div>
</div>
<div class="control-buttons">
<div class="control-button" @click="checkTheme">
<i v-if="lyricSetting.theme === 'light'" class="ri-sun-line"></i>
<i v-else class="ri-moon-line"></i>
</div>
<!-- <div class="control-button" @click="handleTop">
<i class="ri-pushpin-line" :class="{ active: lyricSetting.isTop }"></i>
</div> -->
<div id="lyric-lock" class="control-button" @click="handleLock">
<i v-if="lyricSetting.isLock" class="ri-lock-line"></i>
<i v-else class="ri-lock-unlock-line"></i>
</div>
<div class="control-button" @click="handleClose">
<i class="ri-close-line"></i>
</div>
</div>
</div>
<!-- 歌词显示区域 -->
<div ref="containerRef" class="lyric-container">
<div class="lyric-scroll">
<div class="lyric-wrapper" :style="wrapperStyle">
<template v-if="staticData.lrcArray?.length > 0">
<div
v-for="(line, index) in staticData.lrcArray"
:key="index"
class="lyric-line"
:style="lyricLineStyle"
:class="{
'lyric-line-current': index === currentIndex,
'lyric-line-passed': index < currentIndex,
'lyric-line-next': index === currentIndex + 1
}"
>
<div class="lyric-text" :style="{ fontSize: `${fontSize}px` }">
<span class="lyric-text-inner" :style="getLyricStyle(index)">
{{ line.text || '' }}
</span>
</div>
<div
v-if="line.trText"
class="lyric-translation"
:style="{ fontSize: `${fontSize * 0.6}px` }"
>
{{ line.trText }}
</div>
</div>
</template>
<div v-else class="lyric-empty">无歌词</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { SongResult } from '@/type/music';
defineOptions({
name: 'Lyric'
});
const windowData = window as any;
const containerRef = ref<HTMLElement | null>(null);
const containerHeight = ref(0);
const lineHeight = ref(60);
const currentIndex = ref(0);
//
const fontSize = ref(24); //
const fontSizeStep = 2; //
const animationFrameId = ref<number | null>(null);
const lastUpdateTime = ref(performance.now());
//
const staticData = ref<{
lrcArray: Array<{ text: string; trText: string }>;
lrcTimeArray: number[];
allTime: number;
playMusic: SongResult;
}>({
lrcArray: [],
lrcTimeArray: [],
allTime: 0,
playMusic: {} as SongResult
});
//
const dynamicData = ref({
nowTime: 0,
startCurrentTime: 0,
nextTime: 0,
isPlay: true
});
const lyricSetting = ref({
...(localStorage.getItem('lyricData')
? JSON.parse(localStorage.getItem('lyricData') || '')
: {
isTop: false,
theme: 'dark',
isLock: false
})
});
let hideControlsTimer: number | null = null;
const isHovering = ref(false);
//
const showControls = computed(() => {
if (lyricSetting.value.isLock) {
return isHovering.value;
}
return true;
});
//
const clearHideTimer = () => {
if (hideControlsTimer) {
clearTimeout(hideControlsTimer);
hideControlsTimer = null;
}
};
//
const handleMouseEnter = () => {
if (lyricSetting.value.isLock) {
isHovering.value = true;
windowData.electron.ipcRenderer.send('set-ignore-mouse', true);
} else {
windowData.electron.ipcRenderer.send('set-ignore-mouse', false);
}
};
//
const handleMouseLeave = () => {
if (!lyricSetting.value.isLock) return;
isHovering.value = false;
windowData.electron.ipcRenderer.send('set-ignore-mouse', false);
};
//
watch(
() => lyricSetting.value.isLock,
(newLock: boolean) => {
if (newLock) {
isHovering.value = false;
}
}
);
onMounted(() => {
//
if (lyricSetting.value.isLock) {
isHovering.value = false;
}
});
onUnmounted(() => {
clearHideTimer();
});
//
const wrapperStyle = computed(() => {
if (!containerHeight.value) {
return {
transform: 'translateY(0)',
transition: 'none'
};
}
//
const containerCenter = containerHeight.value / 2;
// padding
const currentLineTop =
currentIndex.value * lineHeight.value + containerHeight.value * 0.2 + lineHeight.value; // padding
// 使
const targetOffset = containerCenter - currentLineTop;
// padding
const contentHeight =
staticData.value.lrcArray.length * lineHeight.value + containerHeight.value * 0.4; // padding20vh
//
const minOffset = -(contentHeight - containerHeight.value);
const maxOffset = 0;
//
const finalOffset = Math.min(maxOffset, Math.max(minOffset, targetOffset));
return {
transform: `translateY(${finalOffset}px)`,
transition: 'transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)'
};
});
const lyricLineStyle = computed(() => ({
height: `${lineHeight.value}px`
}));
//
const updateContainerHeight = () => {
if (!containerRef.value) return;
//
containerHeight.value = containerRef.value.clientHeight;
// (2.5)
const baseLineHeight = fontSize.value * 2.5;
// (1/4)
const maxAllowedHeight = containerHeight.value / 3;
// (40px,)
lineHeight.value = Math.min(maxAllowedHeight, Math.max(40, baseLineHeight));
};
//
const handleFontSizeChange = async () => {
//
saveFontSize();
//
updateContainerHeight();
};
//
const increaseFontSize = async () => {
if (fontSize.value < 48) {
fontSize.value += fontSizeStep;
await handleFontSizeChange();
}
};
//
const decreaseFontSize = async () => {
if (fontSize.value > 12) {
fontSize.value -= fontSizeStep;
await handleFontSizeChange();
}
};
//
const saveFontSize = () => {
localStorage.setItem('lyricFontSize', fontSize.value.toString());
};
//
onMounted(() => {
const resizeObserver = new ResizeObserver(() => {
updateContainerHeight();
});
if (containerRef.value) {
resizeObserver.observe(containerRef.value);
}
onUnmounted(() => {
resizeObserver.disconnect();
});
});
//
const actualTime = ref(0);
//
const currentProgress = computed(() => {
const { startCurrentTime, nextTime } = dynamicData.value;
if (!startCurrentTime || !nextTime) return 0;
const duration = nextTime - startCurrentTime;
const elapsed = actualTime.value - startCurrentTime;
return Math.min(Math.max(elapsed / duration, 0), 1);
});
//
const getLyricStyle = (index: number) => {
if (index !== currentIndex.value) return {};
const progress = currentProgress.value * 100;
return {
background: `linear-gradient(to right, var(--highlight-color) ${progress}%, var(--text-color) ${progress}%)`,
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
transition: 'all 0.1s linear'
};
};
//
const TIME_OFFSET = 400;
//
const updateProgress = () => {
if (!dynamicData.value.isPlay) {
if (animationFrameId.value) {
cancelAnimationFrame(animationFrameId.value);
animationFrameId.value = null;
}
return;
}
//
const timeDiff = (performance.now() - lastUpdateTime.value) / 1000;
actualTime.value = dynamicData.value.nowTime + timeDiff + TIME_OFFSET / 1000;
//
animationFrameId.value = requestAnimationFrame(updateProgress);
};
//
//
watch(
() => dynamicData.value,
(newData: any) => {
//
lastUpdateTime.value = performance.now();
//
actualTime.value = newData.nowTime + TIME_OFFSET / 1000;
//
if (newData.isPlay && !animationFrameId.value) {
updateProgress();
}
},
{ deep: true }
);
//
watch(
() => dynamicData.value.isPlay,
(isPlaying: boolean) => {
if (isPlaying) {
lastUpdateTime.value = performance.now();
updateProgress();
} else if (animationFrameId.value) {
cancelAnimationFrame(animationFrameId.value);
animationFrameId.value = null;
}
}
);
//
const handleDataUpdate = (parsedData: {
nowTime: number;
startCurrentTime: number;
nextTime: number;
isPlay: boolean;
nowIndex: number;
lrcArray: Array<{ text: string; trText: string }>;
lrcTimeArray: number[];
allTime: number;
playMusic: SongResult;
}) => {
//
if (!parsedData) {
console.error('Invalid update data received:', parsedData);
return;
}
//
staticData.value = {
lrcArray: parsedData.lrcArray || [],
lrcTimeArray: parsedData.lrcTimeArray || [],
allTime: parsedData.allTime || 0,
playMusic: parsedData.playMusic || {}
};
//
dynamicData.value = {
nowTime: parsedData.nowTime || 0,
startCurrentTime: parsedData.startCurrentTime || 0,
nextTime: parsedData.nextTime || 0,
isPlay: parsedData.isPlay
};
//
if (typeof parsedData.nowIndex === 'number') {
currentIndex.value = parsedData.nowIndex;
}
};
onMounted(() => {
//
const savedFontSize = localStorage.getItem('lyricFontSize');
if (savedFontSize) {
fontSize.value = Number(savedFontSize);
lineHeight.value = fontSize.value * 2.5;
}
//
updateContainerHeight();
window.addEventListener('resize', updateContainerHeight);
//
windowData.electron.ipcRenderer.on('receive-lyric', (_, data) => {
try {
const parsedData = JSON.parse(data);
handleDataUpdate(parsedData);
} catch (error) {
console.error('Error parsing lyric data:', error);
}
});
});
onUnmounted(() => {
window.removeEventListener('resize', updateContainerHeight);
});
const checkTheme = () => {
if (lyricSetting.value.theme === 'light') {
lyricSetting.value.theme = 'dark';
} else {
lyricSetting.value.theme = 'light';
}
};
// const handleTop = () => {
// lyricSetting.value.isTop = !lyricSetting.value.isTop;
// windowData.electron.ipcRenderer.send('top-lyric', lyricSetting.value.isTop);
// };
const handleLock = () => {
lyricSetting.value.isLock = !lyricSetting.value.isLock;
windowData.electron.ipcRenderer.send('set-ignore-mouse', lyricSetting.value.isLock);
};
const handleClose = () => {
windowData.electron.ipcRenderer.send('close-lyric');
};
watch(
() => lyricSetting.value,
(newValue: any) => {
localStorage.setItem('lyricData', JSON.stringify(newValue));
},
{ deep: true }
);
//
const isDragging = ref(false);
const startPosition = ref({ x: 0, y: 0 });
//
const handleMouseDown = (e: MouseEvent) => {
//
if (
lyricSetting.value.isLock ||
(e.target as HTMLElement).closest('.control-buttons') ||
(e.target as HTMLElement).closest('.font-size-controls')
) {
return;
}
//
if (e.button !== 0) return;
isDragging.value = true;
startPosition.value = { x: e.screenX, y: e.screenY };
//
const handleMouseMove = (e: MouseEvent) => {
if (!isDragging.value) return;
const deltaX = e.screenX - startPosition.value.x;
const deltaY = e.screenY - startPosition.value.y;
//
windowData.electron.ipcRenderer.send('lyric-drag-move', { deltaX, deltaY });
startPosition.value = { x: e.screenX, y: e.screenY };
};
const handleMouseUp = () => {
if (!isDragging.value) return;
isDragging.value = false;
//
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
//
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
};
//
onUnmounted(() => {
isDragging.value = false;
});
onMounted(() => {
const lyricLock = document.getElementById('lyric-lock');
if (lyricLock) {
lyricLock.onmouseenter = () => {
if (lyricSetting.value.isLock) {
windowData.electron.ipcRenderer.send('set-ignore-mouse', false);
}
};
lyricLock.onmouseleave = () => {
if (lyricSetting.value.isLock) {
windowData.electron.ipcRenderer.send('set-ignore-mouse', true);
}
};
}
});
//
const handlePlayPause = () => {
windowData.electron.ipcRenderer.send('control-back', 'playpause');
};
const handlePrev = () => {
windowData.electron.ipcRenderer.send('control-back', 'prev');
};
const handleNext = () => {
windowData.electron.ipcRenderer.send('control-back', 'next');
};
</script>
<style>
body {
background-color: transparent !important;
}
</style>
<style lang="scss" scoped>
.lyric-window {
width: 100vw;
height: 100vh;
position: relative;
overflow: hidden;
background: transparent;
user-select: none;
transition: background-color 0.2s ease;
cursor: default;
&:hover {
background: rgba(0, 0, 0, 0.5);
.control-bar {
&-show {
opacity: 1;
visibility: visible;
}
}
}
&:active {
cursor: grabbing;
}
&.dark {
--text-color: #ffffff;
--text-secondary: rgba(255, 255, 255, 0.6);
--highlight-color: #1db954;
--control-bg: rgba(124, 124, 124, 0.3);
}
&.light {
--text-color: #333333;
--text-secondary: rgba(51, 51, 51, 0.6);
--highlight-color: #1db954;
--control-bg: rgba(255, 255, 255, 0.3);
}
}
.control-bar {
position: absolute;
top: 10px;
left: 0;
right: 0;
height: 80px;
display: flex;
justify-content: space-between;
align-items: start;
padding: 0 20px;
opacity: 0;
visibility: hidden;
transition:
opacity 0.2s ease,
visibility 0.2s ease;
z-index: 100;
.font-size-controls {
-webkit-app-region: no-drag;
color: var(--text-color);
display: flex;
align-items: center;
gap: 16px;
}
.play-controls {
position: absolute;
top: 0px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 16px;
-webkit-app-region: no-drag;
.play-button {
width: 36px;
height: 36px;
i {
font-size: 24px;
}
}
}
.control-buttons {
-webkit-app-region: no-drag;
}
}
.control-buttons {
display: flex;
gap: 16px;
-webkit-app-region: no-drag;
}
.control-button {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 8px;
color: var(--text-color);
transition: all 0.2s ease;
&:hover {
background: var(--control-bg);
}
i {
font-size: 20px;
text-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
&.active {
color: var(--highlight-color);
}
}
}
.lyric-container {
position: absolute;
top: 80px;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
z-index: 100;
}
.lyric-scroll {
height: 100%;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
mask-image: linear-gradient(to bottom, transparent 0%, black 20%, black 80%, transparent 100%);
}
.lyric-wrapper {
will-change: transform;
padding: 20vh 0;
transform-origin: center center;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.lyric-line {
padding: 4px 20px;
text-align: center;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
&.lyric-line-current {
transform: scale(1.05);
opacity: 1;
}
&.lyric-line-passed {
opacity: 0.6;
}
}
.lyric-text {
font-weight: 600;
margin-bottom: 2px;
color: var(--text-color);
white-space: pre-wrap;
word-break: break-all;
text-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
transition: all 0.2s ease;
line-height: 1.4;
}
.lyric-translation {
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-all;
text-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
transition: font-size 0.2s ease;
line-height: 1.4; //
}
.lyric-empty {
text-align: center;
color: var(--text-secondary);
font-size: 16px;
padding: 20px;
}
body {
background-color: transparent !important;
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell,
'Open Sans', 'Helvetica Neue', sans-serif;
}
.lyric-content {
transition: font-size 0.2s ease;
}
.lyric-line-current {
opacity: 1;
}
.control-bar {
.control-buttons {
.control-button {
&:not(:has(.ri-lock-line)):not(:has(.ri-lock-unlock-line)) {
.lyric_lock & {
display: none;
}
}
}
}
.lyric_lock & .font-size-controls {
display: none;
}
.lyric_lock & .play-controls {
display: none;
}
}
.lyric_lock {
background: transparent;
&:hover {
background: transparent;
}
#lyric-lock {
position: absolute;
top: 0;
right: 72px;
background: var(--control-bg);
}
}
</style>
+295
View File
@@ -0,0 +1,295 @@
<template>
<div class="mv-list">
<div class="play-list-type">
<n-scrollbar x-scrollable>
<div class="categories-wrapper">
<span
v-for="(category, index) in categories"
:key="category.value"
class="play-list-type-item"
:class="[
setAnimationClass('animate__bounceIn'),
{ active: selectedCategory === category.value }
]"
:style="getAnimationDelay(index)"
@click="selectedCategory = category.value"
>
{{ category.label }}
</span>
</div>
</n-scrollbar>
</div>
<n-scrollbar :size="100" @scroll="handleScroll">
<div
v-loading="initLoading"
class="mv-list-content"
:class="setAnimationClass('animate__bounceInLeft')"
>
<div
v-for="(item, index) in mvList"
:key="item.id"
class="mv-item"
:class="setAnimationClass('animate__bounceIn')"
:style="getAnimationDelay(index)"
>
<div class="mv-item-img" @click="handleShowMv(item, index)">
<n-image
class="mv-item-img-img"
:src="getImgUrl(item.cover, '320y180')"
lazy
preview-disabled
/>
<div class="top">
<div class="play-count">{{ formatNumber(item.playCount) }}</div>
<i class="iconfont icon-videofill"></i>
</div>
</div>
<div class="mv-item-title">{{ item.name }}</div>
</div>
<div v-if="loadingMore" class="loading-more">加载中...</div>
<div v-if="!hasMore && !initLoading" class="no-more">没有更多了</div>
</div>
</n-scrollbar>
<mv-player
v-model:show="showMv"
:current-mv="playMvItem"
:is-prev-disabled="isPrevDisabled"
@next="playNextMv"
@prev="playPrevMv"
/>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { useStore } from 'vuex';
import { getAllMv, getTopMv } from '@/api/mv';
import MvPlayer from '@/components/MvPlayer.vue';
import { audioService } from '@/services/audioService';
import { IMvItem } from '@/type/mv';
import { formatNumber, getImgUrl, setAnimationClass, setAnimationDelay } from '@/utils';
defineOptions({
name: 'Mv'
});
const showMv = ref(false);
const mvList = ref<Array<IMvItem>>([]);
const playMvItem = ref<IMvItem>();
const store = useStore();
const initLoading = ref(false);
const loadingMore = ref(false);
const currentIndex = ref(0);
const offset = ref(0);
const limit = ref(42);
const hasMore = ref(true);
const categories = [
{ label: '全部', value: '全部' },
{ label: '内地', value: '内地' },
{ label: '港台', value: '港台' },
{ label: '欧美', value: '欧美' },
{ label: '日本', value: '日本' },
{ label: '韩国', value: '韩国' }
];
const selectedCategory = ref('全部');
watch(selectedCategory, async () => {
offset.value = 0;
mvList.value = [];
hasMore.value = true;
await loadMvList();
});
const getAnimationDelay = (index: number) => {
const currentPageIndex = index % limit.value;
return setAnimationDelay(currentPageIndex, 30);
};
onMounted(async () => {
await loadMvList();
});
const handleShowMv = async (item: IMvItem, index: number) => {
store.commit('setIsPlay', false);
store.commit('setPlayMusic', false);
audioService.getCurrentSound()?.pause();
showMv.value = true;
currentIndex.value = index;
playMvItem.value = item;
};
const playPrevMv = async (setLoading: (value: boolean) => void) => {
try {
if (currentIndex.value > 0) {
const prevItem = mvList.value[currentIndex.value - 1];
await handleShowMv(prevItem, currentIndex.value - 1);
}
} finally {
setLoading(false);
}
};
const playNextMv = async (setLoading: (value: boolean) => void) => {
try {
if (currentIndex.value < mvList.value.length - 1) {
const nextItem = mvList.value[currentIndex.value + 1];
await handleShowMv(nextItem, currentIndex.value + 1);
} else if (hasMore.value) {
await loadMvList();
if (mvList.value.length > currentIndex.value + 1) {
const nextItem = mvList.value[currentIndex.value + 1];
await handleShowMv(nextItem, currentIndex.value + 1);
} else {
showMv.value = false;
}
} else {
showMv.value = false;
}
} catch (error) {
console.error('加载更多MV失败:', error);
showMv.value = false;
} finally {
setLoading(false);
}
};
const loadMvList = async () => {
try {
if (!hasMore.value || loadingMore.value) return;
if (offset.value === 0) {
initLoading.value = true;
} else {
loadingMore.value = true;
}
const params = {
limit: limit.value,
offset: offset.value,
area: selectedCategory.value === '全部' ? '' : selectedCategory.value
};
const res = selectedCategory.value === '全部' ? await getTopMv(params) : await getAllMv(params);
const { data } = res.data;
mvList.value.push(...data);
hasMore.value = data.length === limit.value;
offset.value += limit.value;
} finally {
initLoading.value = false;
loadingMore.value = false;
}
};
const handleScroll = (e: Event) => {
const target = e.target as Element;
const { scrollTop, clientHeight, scrollHeight } = target;
const threshold = 100;
if (scrollHeight - (scrollTop + clientHeight) < threshold) {
loadMvList();
}
};
const isPrevDisabled = computed(() => currentIndex.value === 0);
</script>
<style scoped lang="scss">
.mv-list {
@apply h-full flex-1 flex flex-col overflow-hidden;
&-title {
@apply text-xl font-bold pb-2;
@apply text-gray-900 dark:text-white;
}
//
.play-list-type {
.title {
@apply text-lg font-bold mb-2;
@apply text-gray-900 dark:text-white;
}
.categories-wrapper {
@apply flex items-center py-2;
white-space: nowrap;
}
&-item {
@apply py-2 px-3 mr-3 inline-block rounded-xl cursor-pointer transition-all duration-300;
@apply bg-light dark:bg-black text-gray-900 dark:text-white;
@apply border border-gray-200 dark:border-gray-700;
&:hover {
@apply bg-green-50 dark:bg-green-900;
}
&.active {
@apply bg-green-500 border-green-500 text-white;
}
}
}
&-content {
@apply grid gap-4 pb-28 mt-2 pr-4;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
}
.mv-item {
@apply p-2 rounded-lg;
@apply bg-light dark:bg-black;
@apply border border-gray-200 dark:border-gray-700;
&-img {
@apply rounded-lg overflow-hidden relative;
aspect-ratio: 16/9;
line-height: 0;
&:hover img {
@apply hover:scale-110 transition-all duration-300 ease-in-out object-top;
}
&-img {
@apply w-full h-full object-cover rounded-lg overflow-hidden;
}
.top {
@apply absolute w-full h-full top-0 left-0 flex justify-center items-center transition-all duration-300 ease-in-out cursor-pointer;
@apply bg-black bg-opacity-60;
opacity: 0;
i {
@apply text-4xl text-white;
}
.play-count {
@apply absolute top-2 right-2 text-sm;
@apply text-white text-opacity-90;
}
&:hover {
opacity: 1;
}
}
}
&-title {
@apply mt-2 text-sm line-clamp-1;
@apply text-gray-900 dark:text-white;
}
}
}
.loading-more {
@apply text-center py-4 col-span-full;
@apply text-gray-500 dark:text-gray-400;
}
.no-more {
@apply text-center py-4 col-span-full;
@apply text-gray-500 dark:text-gray-400;
}
</style>
+224
View File
@@ -0,0 +1,224 @@
<template>
<div class="search-page">
<n-layout
v-if="isMobile ? !searchDetail : true"
class="hot-search"
:class="setAnimationClass('animate__fadeInDown')"
:native-scrollbar="false"
>
<div class="title">热搜列表</div>
<div class="hot-search-list">
<template v-for="(item, index) in hotSearchData?.data" :key="index">
<div
:class="setAnimationClass('animate__bounceInLeft')"
:style="setAnimationDelay(index, 10)"
class="hot-search-item"
@click.stop="loadSearch(item.searchWord, 1)"
>
<span class="hot-search-item-count" :class="{ 'hot-search-item-count-3': index < 3 }">{{
index + 1
}}</span>
{{ item.searchWord }}
</div>
</template>
</div>
</n-layout>
<!-- 搜索到的歌曲列表 -->
<n-layout
v-if="isMobile ? searchDetail : true"
class="search-list"
:class="setAnimationClass('animate__fadeInUp')"
:native-scrollbar="false"
>
<div class="title">{{ hotKeyword }}</div>
<div v-loading="searchDetailLoading" class="search-list-box">
<template v-if="searchDetail">
<div
v-for="(item, index) in searchDetail?.songs"
:key="item.id"
:class="setAnimationClass('animate__bounceInRight')"
:style="setAnimationDelay(index, 50)"
>
<song-item :item="item" @play="handlePlay" />
</div>
<template v-for="(list, key) in searchDetail">
<template v-if="key.toString() !== 'songs'">
<div
v-for="(item, index) in list"
:key="item.id"
:class="setAnimationClass('animate__bounceInRight')"
:style="setAnimationDelay(index, 50)"
>
<SearchItem :item="item" />
</div>
</template>
</template>
</template>
</div>
</n-layout>
</div>
</template>
<script lang="ts" setup>
import { useDateFormat } from '@vueuse/core';
import { onMounted, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useStore } from 'vuex';
import { getHotSearch } from '@/api/home';
import { getSearch } from '@/api/search';
import SongItem from '@/components/common/SongItem.vue';
import type { IHotSearch } from '@/type/search';
import { isMobile, setAnimationClass, setAnimationDelay } from '@/utils';
import SearchItem from '@/components/common/SearchItem.vue';
defineOptions({
name: 'Search'
});
const route = useRoute();
const store = useStore();
const searchDetail = ref<any>();
const searchType = computed(() => store.state.searchType as number);
const searchDetailLoading = ref(false);
//
const hotSearchData = ref<IHotSearch>();
const loadHotSearch = async () => {
const { data } = await getHotSearch();
hotSearchData.value = data;
};
onMounted(() => {
loadHotSearch();
loadSearch(route.query.keyword);
});
const hotKeyword = ref(route.query.keyword || '搜索列表');
watch(
() => store.state.searchValue,
(value) => {
loadSearch(value);
}
);
const dateFormat = (time: any) => useDateFormat(time, 'YYYY.MM.DD').value;
const loadSearch = async (keywords: any, type: any = null) => {
hotKeyword.value = keywords;
searchDetail.value = undefined;
if (!keywords) return;
searchDetailLoading.value = true;
const { data } = await getSearch({ keywords, type: type || searchType.value });
const songs = data.result.songs || [];
const albums = data.result.albums || [];
const mvs = (data.result.mvs || []).map((item: any) => ({
...item,
picUrl: item.cover,
playCount: item.playCount,
desc: item.artists.map((artist: any) => artist.name).join('/'),
type: 'mv'
}));
const playlists = (data.result.playlists || []).map((item: any) => ({
...item,
picUrl: item.coverImgUrl,
playCount: item.playCount,
desc: item.creator.nickname,
type: 'playlist'
}));
// songs map
songs.forEach((item: any) => {
item.picUrl = item.al.picUrl;
item.artists = item.ar;
});
albums.forEach((item: any) => {
item.desc = `${item.artist.name} ${item.company} ${dateFormat(item.publishTime)}`;
});
searchDetail.value = {
songs,
albums,
mvs,
playlists
};
searchDetailLoading.value = false;
};
watch(
() => route.path,
async (path) => {
if (path === '/search') {
store.state.searchValue = route.query.keyword;
}
}
);
const handlePlay = () => {
const tracks = searchDetail.value?.songs || [];
store.commit('setPlayList', tracks);
};
</script>
<style lang="scss" scoped>
.search-page {
@apply flex h-full;
}
.hot-search {
@apply mr-4 rounded-xl flex-1 overflow-hidden;
@apply bg-light-100 dark:bg-dark-100;
animation-duration: 0.2s;
min-width: 400px;
height: 100%;
&-list {
@apply pb-28;
}
&-item {
@apply px-4 py-3 text-lg rounded-xl cursor-pointer;
@apply text-gray-900 dark:text-white;
transition: all 0.3s ease;
&:hover {
@apply bg-light-100 dark:bg-dark-200;
}
&-count {
@apply inline-block ml-3 w-8;
@apply text-green-500;
&-3 {
@apply font-bold inline-block ml-3 w-8;
@apply text-red-500;
}
}
}
}
.search-list {
@apply flex-1 rounded-xl;
@apply bg-light-100 dark:bg-dark-100;
height: 100%;
&-box {
@apply pb-28;
}
}
.title {
@apply text-xl font-bold my-2 mx-4;
@apply text-gray-900 dark:text-white;
}
.mobile {
.hot-search {
@apply mr-0 w-full;
}
}
</style>
+192
View File
@@ -0,0 +1,192 @@
<template>
<n-scrollbar>
<div class="set-page">
<div class="set-item">
<div>
<div class="set-item-title">主题模式</div>
<div class="set-item-content">切换日间/夜间主题</div>
</div>
<n-switch v-model:value="isDarkTheme">
<template #checked>
<i class="ri-moon-line"></i>
</template>
<template #unchecked>
<i class="ri-sun-line"></i>
</template>
</n-switch>
</div>
<!-- <div v-if="isElectron" class="set-item">
<div>
<div class="set-item-title">代理</div>
<div class="set-item-content">无法听音乐时打开</div>
</div>
<n-switch v-model:value="setData.isProxy" />
</div> -->
<div class="set-item" v-if="isElectron">
<div>
<div class="set-item-title">音乐API端口</div>
<div class="set-item-content">
修改后需要重启应用
</div>
</div>
<n-input-number v-model:value="setData.musicApiPort" />
</div>
<div class="set-item">
<div>
<div class="set-item-title">动画速度</div>
<div class="set-item-content">调节动画播放速度</div>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-400">{{ setData.animationSpeed }}x</span>
<div class="w-40">
<n-slider
v-model:value="setData.animationSpeed"
:min="0.1"
:max="3"
:step="0.1"
:marks="{
0.1: '极慢',
1: '正常',
3: '极快'
}"
:disabled="setData.noAnimate"
class="w-40"
/>
</div>
</div>
</div>
<div class="set-item">
<div>
<div class="set-item-title">版本</div>
<div class="set-item-content">
{{ updateInfo.currentVersion }}
<template v-if="updateInfo.hasUpdate">
<n-tag type="success" class="ml-2">发现新版本 {{ updateInfo.latestVersion }}</n-tag>
</template>
</div>
</div>
<div class="flex items-center gap-2">
<n-button
:type="updateInfo.hasUpdate ? 'primary' : 'default'"
size="small"
:loading="checking"
@click="checkForUpdates(true)"
>
{{ checking ? '检查中...' : '检查更新' }}
</n-button>
<n-button
v-if="updateInfo.hasUpdate"
type="success"
size="small"
@click="openReleasePage"
>
前往更新
</n-button>
</div>
</div>
<div
class="set-item cursor-pointer hover:text-green-500 hover:bg-green-950 transition-all"
@click="openAuthor"
>
<div>
<div class="set-item-title">作者</div>
<div class="set-item-content">algerkong github</div>
</div>
<div>{{ setData.author }}</div>
</div>
<div class="set-item">
<div>
<div class="set-item-title">重启</div>
<div class="set-item-content">重启应用</div>
</div>
<n-button type="primary" @click="restartApp">重启</n-button>
</div>
</div>
<PlayBottom/>
</n-scrollbar>
</template>
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue';
import { useStore } from 'vuex';
import { isElectron, checkUpdate } from '@/utils';
import config from '../../../../package.json';
import PlayBottom from '@/components/common/PlayBottom.vue';
const store = useStore();
const checking = ref(false);
const updateInfo = ref({
hasUpdate: false,
latestVersion: '',
currentVersion: config.version,
releaseInfo: null
});
const setData = computed(() => store.state.setData);
watch(() => setData.value, (newVal) => {
store.commit('setSetData', newVal)
}, { deep: true });
const isDarkTheme = computed({
get: () => store.state.theme === 'dark',
set: () => store.commit('toggleTheme')
});
const openAuthor = () => {
window.open(setData.value.authorUrl);
};
const restartApp = () => {
window.electron.ipcRenderer.send('restart');
};
const message = useMessage();
const checkForUpdates = async (isClick = false) => {
checking.value = true;
try {
const result = await checkUpdate();
updateInfo.value = result;
if (!result.hasUpdate && isClick) {
message.success('当前已是最新版本');
}
} finally {
checking.value = false;
}
};
const openReleasePage = () => {
window.open('https://github.com/algerkong/AlgerMusicPlayer/releases/latest');
};
onMounted(() => {
checkForUpdates();
});
</script>
<style lang="scss" scoped>
.set-page {
@apply p-4 bg-light dark:bg-dark;
}
.set-item {
@apply flex items-center justify-between p-4 rounded-lg mb-4 transition-all;
@apply bg-light dark:bg-dark text-gray-900 dark:text-white;
@apply border border-gray-200 dark:border-gray-700;
&-title {
@apply text-base font-medium mb-1;
}
&-content {
@apply text-sm text-gray-500 dark:text-gray-400;
}
&:hover {
@apply bg-gray-50 dark:bg-gray-800;
}
&.cursor-pointer:hover {
@apply text-green-500 bg-green-50 dark:bg-green-900;
}
}
</style>
+277
View File
@@ -0,0 +1,277 @@
<template>
<div class="user-page">
<div
v-if="userDetail"
class="left"
:class="setAnimationClass('animate__fadeInLeft')"
:style="{ backgroundImage: `url(${getImgUrl(user.backgroundUrl)})` }"
>
<div class="page">
<div class="user-name">{{ user.nickname }}</div>
<div class="user-info">
<n-avatar round :size="50" :src="getImgUrl(user.avatarUrl, '50y50')" />
<div class="user-info-list">
<div class="user-info-item">
<div class="label">{{ userDetail.profile.followeds }}</div>
<div>粉丝</div>
</div>
<div class="user-info-item">
<div class="label">{{ userDetail.profile.follows }}</div>
<div>关注</div>
</div>
<div class="user-info-item">
<div class="label">{{ userDetail.level }}</div>
<div>等级</div>
</div>
</div>
</div>
<div class="uesr-signature">{{ userDetail.profile.signature }}</div>
<div class="play-list" :class="setAnimationClass('animate__fadeInLeft')">
<div class="title">创建的歌单</div>
<n-scrollbar>
<div
v-for="(item, index) in playList"
:key="index"
class="play-list-item"
@click="showPlaylist(item.id, item.name)"
>
<n-image
:src="getImgUrl(item.coverImgUrl, '50y50')"
class="play-list-item-img"
lazy
preview-disabled
/>
<div class="play-list-item-info">
<div class="play-list-item-name">{{ item.name }}</div>
<div class="play-list-item-count">
{{ item.trackCount }}播放{{ item.playCount }}
</div>
</div>
</div>
<play-bottom />
</n-scrollbar>
</div>
</div>
</div>
<div
v-if="!isMobile"
v-loading="infoLoading"
class="right"
:class="setAnimationClass('animate__fadeInRight')"
>
<div class="title">听歌排行</div>
<div class="record-list">
<n-scrollbar>
<div
v-for="(item, index) in recordList"
:key="item.id"
class="record-item"
:class="setAnimationClass('animate__bounceInUp')"
:style="setAnimationDelay(index, 25)"
>
<song-item class="song-item" :item="item" @play="handlePlay" />
<div class="play-count">{{ item.playCount }}</div>
</div>
<play-bottom />
</n-scrollbar>
</div>
</div>
<music-list
v-model:show="isShowList"
:name="list?.name || ''"
:song-list="list?.tracks || []"
:list-info="list"
:loading="listLoading"
/>
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useStore } from 'vuex';
import { getListDetail } from '@/api/list';
import { getUserDetail, getUserPlaylist, getUserRecord } from '@/api/user';
import PlayBottom from '@/components/common/PlayBottom.vue';
import SongItem from '@/components/common/SongItem.vue';
import MusicList from '@/components/MusicList.vue';
import type { Playlist } from '@/type/listDetail';
import type { IUserDetail } from '@/type/user';
import { getImgUrl, isMobile, setAnimationClass, setAnimationDelay } from '@/utils';
defineOptions({
name: 'User'
});
const store = useStore();
const router = useRouter();
const userDetail = ref<IUserDetail>();
const playList = ref<any[]>([]);
const recordList = ref();
const infoLoading = ref(false);
const user = computed(() => store.state.user);
const loadPage = async () => {
if (!user.value) {
router.push('/login');
return;
}
infoLoading.value = true;
const { data: userData } = await getUserDetail(user.value.userId);
userDetail.value = userData;
const { data: playlistData } = await getUserPlaylist(user.value.userId);
playList.value = playlistData.playlist;
const { data: recordData } = await getUserRecord(user.value.userId);
recordList.value = recordData.allData.map((item: any) => ({
...item,
...item.song,
picUrl: item.song.al.picUrl
}));
infoLoading.value = false;
};
onActivated(() => {
if (!user.value) {
router.push('/login');
} else {
loadPage();
}
});
const isShowList = ref(false);
const list = ref<Playlist>();
const listLoading = ref(false);
//
const showPlaylist = async (id: number, name: string) => {
isShowList.value = true;
listLoading.value = true;
list.value = {
name
} as Playlist;
const { data } = await getListDetail(id);
list.value = data.playlist;
listLoading.value = false;
};
const handlePlay = () => {
const tracks = recordList.value || [];
store.commit('setPlayList', tracks);
};
</script>
<style lang="scss" scoped>
.user-page {
@apply flex h-full;
.left {
max-width: 600px;
@apply flex-1 rounded-2xl overflow-hidden relative bg-no-repeat h-full;
@apply bg-gray-900 dark:bg-gray-800;
.page {
@apply p-4 w-full z-10 flex flex-col h-full;
@apply bg-black bg-opacity-40;
}
.title {
@apply text-lg font-bold;
@apply text-gray-900 dark:text-white;
}
.user-name {
@apply text-xl font-bold mb-4;
@apply text-white text-opacity-70;
}
.uesr-signature {
@apply mt-4;
@apply text-white text-opacity-70;
}
.user-info {
@apply flex items-center;
&-list {
@apply flex justify-around w-2/5 text-center;
@apply text-white text-opacity-70;
.label {
@apply text-xl font-bold text-white;
}
}
}
}
.right {
@apply flex-1 ml-4 overflow-hidden h-full;
.record-list {
@apply rounded-2xl;
@apply bg-light dark:bg-black;
height: calc(100% - 3.75rem);
.record-item {
@apply flex items-center px-4;
}
.song-item {
@apply flex-1;
}
.play-count {
@apply ml-4;
@apply text-gray-600 dark:text-gray-400;
}
}
.title {
@apply text-xl font-bold m-4;
@apply text-gray-900 dark:text-white;
}
}
}
.play-list {
@apply mt-4 py-4 px-2 rounded-xl flex-1 overflow-hidden;
@apply bg-light dark:bg-black;
&-title {
@apply text-lg;
@apply text-gray-900 dark:text-white;
}
&-item {
@apply flex items-center px-2 py-1 rounded-xl cursor-pointer;
@apply transition-all duration-200;
@apply hover:bg-light-200 dark:hover:bg-dark-200;
&-img {
width: 60px;
height: 60px;
@apply rounded-xl;
}
&-info {
@apply ml-2;
}
&-name {
@apply text-gray-900 dark:text-white text-base;
}
&-count {
@apply text-gray-500 dark:text-gray-400;
}
}
}
.mobile {
.user-page {
@apply px-4;
}
}
</style>
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />