mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-03 14:20:50 +08:00
✨ feat: 优化音乐解析,添加搜索记录 添加搜索滚动加载更多 添加关闭动画功能
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -19,4 +19,6 @@ bun.lockb
|
||||
|
||||
.env.*.local
|
||||
|
||||
out
|
||||
out
|
||||
|
||||
.cursorrules
|
||||
@@ -24,17 +24,18 @@ let mainWindow: Electron.BrowserWindow;
|
||||
|
||||
// 初始化应用
|
||||
function initialize() {
|
||||
// 初始化各个模块
|
||||
// 初始化配置管理
|
||||
initializeConfig();
|
||||
initializeFileManager();
|
||||
// 初始化缓存管理
|
||||
initializeCacheManager();
|
||||
// 初始化文件管理
|
||||
initializeFileManager();
|
||||
// 初始化窗口管理
|
||||
initializeWindowManager();
|
||||
|
||||
// 创建主窗口
|
||||
mainWindow = createMainWindow(icon);
|
||||
|
||||
// 初始化窗口管理
|
||||
initializeWindowManager();
|
||||
|
||||
// 初始化托盘
|
||||
initializeTray(iconPath, mainWindow);
|
||||
|
||||
|
||||
@@ -1,23 +1,162 @@
|
||||
import match from '@unblockneteasemusic/server';
|
||||
import Store from 'electron-store';
|
||||
|
||||
const unblockMusic = async (id: any, songData: any): Promise<any> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
match(parseInt(id, 10), ['qq', 'migu', 'kugou', 'joox'], songData)
|
||||
.then((data) => {
|
||||
resolve({
|
||||
data: {
|
||||
data,
|
||||
params: {
|
||||
id,
|
||||
type: 'song'
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
reject(err);
|
||||
});
|
||||
type Platform = 'qq' | 'migu' | 'kugou' | 'pyncmd' | 'joox' | 'kuwo' | 'bilibili' | 'youtube';
|
||||
|
||||
interface SongData {
|
||||
name: string;
|
||||
artists: Array<{ name: string }>;
|
||||
album?: { name: string };
|
||||
}
|
||||
|
||||
interface ResponseData {
|
||||
url: string;
|
||||
br: number;
|
||||
size: number;
|
||||
md5?: string;
|
||||
platform?: Platform;
|
||||
gain?: number;
|
||||
}
|
||||
|
||||
interface UnblockResult {
|
||||
data: {
|
||||
data: ResponseData;
|
||||
params: {
|
||||
id: number;
|
||||
type: 'song';
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface CacheData extends UnblockResult {
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface CacheStore {
|
||||
[key: string]: CacheData;
|
||||
}
|
||||
|
||||
// 初始化缓存存储
|
||||
const store = new Store<CacheStore>({
|
||||
name: 'unblock-cache'
|
||||
});
|
||||
|
||||
// 缓存过期时间(24小时)
|
||||
const CACHE_EXPIRY = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 检查缓存是否有效
|
||||
* @param cacheData 缓存数据
|
||||
* @returns boolean
|
||||
*/
|
||||
const isCacheValid = (cacheData: CacheData | null): boolean => {
|
||||
if (!cacheData) return false;
|
||||
const now = Date.now();
|
||||
return now - cacheData.timestamp < CACHE_EXPIRY;
|
||||
};
|
||||
|
||||
/**
|
||||
* 从缓存中获取数据
|
||||
* @param id 歌曲ID
|
||||
* @returns CacheData | null
|
||||
*/
|
||||
const getFromCache = (id: string | number): CacheData | null => {
|
||||
const cacheData = store.get(String(id)) as CacheData | null;
|
||||
if (isCacheValid(cacheData)) {
|
||||
return cacheData;
|
||||
}
|
||||
// 清除过期缓存
|
||||
store.delete(String(id));
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 将数据存入缓存
|
||||
* @param id 歌曲ID
|
||||
* @param data 解析结果
|
||||
*/
|
||||
const saveToCache = (id: string | number, data: UnblockResult): void => {
|
||||
const cacheData: CacheData = {
|
||||
...data,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
store.set(String(id), cacheData);
|
||||
};
|
||||
|
||||
/**
|
||||
* 清理过期缓存
|
||||
*/
|
||||
const cleanExpiredCache = (): void => {
|
||||
const allData = store.store;
|
||||
Object.entries(allData).forEach(([id, data]) => {
|
||||
if (!isCacheValid(data)) {
|
||||
store.delete(id);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export { unblockMusic };
|
||||
/**
|
||||
* 音乐解析函数
|
||||
* @param id 歌曲ID
|
||||
* @param songData 歌曲信息
|
||||
* @param retryCount 重试次数
|
||||
* @returns Promise<UnblockResult>
|
||||
*/
|
||||
const unblockMusic = async (
|
||||
id: number | string,
|
||||
songData: SongData,
|
||||
retryCount = 3
|
||||
): Promise<UnblockResult> => {
|
||||
// 检查缓存
|
||||
const cachedData = getFromCache(id);
|
||||
if (cachedData) {
|
||||
return cachedData;
|
||||
}
|
||||
|
||||
// 所有可用平台
|
||||
const platforms: Platform[] = ['migu', 'kugou', 'pyncmd', 'joox', 'kuwo', 'bilibili', 'youtube'];
|
||||
|
||||
const retry = async (attempt: number): Promise<UnblockResult> => {
|
||||
try {
|
||||
const data = await match(parseInt(String(id), 10), platforms, songData);
|
||||
const result: UnblockResult = {
|
||||
data: {
|
||||
data,
|
||||
params: {
|
||||
id: parseInt(String(id), 10),
|
||||
type: 'song'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 保存到缓存
|
||||
saveToCache(id, result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
if (attempt < retryCount) {
|
||||
// 延迟重试,每次重试增加延迟时间
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
|
||||
return retry(attempt + 1);
|
||||
}
|
||||
|
||||
// 所有重试都失败后,抛出详细错误
|
||||
throw new Error(
|
||||
`音乐解析失败 (ID: ${id}): ${err instanceof Error ? err.message : '未知错误'}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return retry(1);
|
||||
};
|
||||
|
||||
// 定期清理过期缓存(每小时执行一次)
|
||||
setInterval(cleanExpiredCache, 60 * 60 * 1000);
|
||||
|
||||
export {
|
||||
cleanExpiredCache, // 导出清理缓存函数,以便手动调用
|
||||
type Platform,
|
||||
type ResponseData,
|
||||
type SongData,
|
||||
unblockMusic,
|
||||
type UnblockResult
|
||||
};
|
||||
|
||||
@@ -3,6 +3,8 @@ import request from '@/utils/request';
|
||||
interface IParams {
|
||||
keywords: string;
|
||||
type: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
// 搜索内容
|
||||
export const getSearch = (params: IParams) => {
|
||||
|
||||
@@ -23,6 +23,9 @@ export const setAnimationClass = (type: String) => {
|
||||
};
|
||||
// 设置动画延时
|
||||
export const setAnimationDelay = (index: number = 6, time: number = 50) => {
|
||||
if (store.state.setData?.noAnimate) {
|
||||
return '';
|
||||
}
|
||||
const speed = store.state.setData?.animationSpeed || 1;
|
||||
return `animation-delay:${(index * time) / (speed * 2)}ms`;
|
||||
};
|
||||
|
||||
@@ -29,8 +29,15 @@
|
||||
class="search-list"
|
||||
:class="setAnimationClass('animate__fadeInUp')"
|
||||
:native-scrollbar="false"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<div class="title">{{ hotKeyword }}</div>
|
||||
<div v-if="searchDetail" class="title">
|
||||
<i
|
||||
class="ri-arrow-left-s-line mr-1 cursor-pointer hover:text-gray-500 hover:scale-110"
|
||||
@click="searchDetail = null"
|
||||
></i>
|
||||
{{ hotKeyword }}
|
||||
</div>
|
||||
<div v-loading="searchDetailLoading" class="search-list-box">
|
||||
<template v-if="searchDetail">
|
||||
<div
|
||||
@@ -53,6 +60,41 @@
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="isLoadingMore" class="loading-more">
|
||||
<n-spin size="small" />
|
||||
<span class="ml-2">加载中...</span>
|
||||
</div>
|
||||
<div v-if="!hasMore && searchDetail" class="no-more">没有更多了</div>
|
||||
</template>
|
||||
<!-- 搜索历史 -->
|
||||
<template v-else>
|
||||
<div class="search-history">
|
||||
<div class="search-history-header title">
|
||||
<span>搜索历史</span>
|
||||
<n-button text type="error" @click="clearSearchHistory">
|
||||
<template #icon>
|
||||
<i class="ri-delete-bin-line"></i>
|
||||
</template>
|
||||
清空
|
||||
</n-button>
|
||||
</div>
|
||||
<div class="search-history-list">
|
||||
<n-tag
|
||||
v-for="(item, index) in searchHistory"
|
||||
:key="index"
|
||||
:class="setAnimationClass('animate__bounceInLeft')"
|
||||
:style="setAnimationDelay(index, 10)"
|
||||
class="search-history-item"
|
||||
round
|
||||
closable
|
||||
@click="handleSearchHistory(item)"
|
||||
@close="handleCloseSearchHistory(item)"
|
||||
>
|
||||
{{ item }}
|
||||
</n-tag>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</n-layout>
|
||||
@@ -82,6 +124,51 @@ const store = useStore();
|
||||
const searchDetail = ref<any>();
|
||||
const searchType = computed(() => store.state.searchType as number);
|
||||
const searchDetailLoading = ref(false);
|
||||
const searchHistory = ref<string[]>([]);
|
||||
|
||||
// 添加分页相关的状态
|
||||
const ITEMS_PER_PAGE = 30; // 每页数量
|
||||
const page = ref(0);
|
||||
const hasMore = ref(true);
|
||||
const isLoadingMore = ref(false);
|
||||
const currentKeyword = ref('');
|
||||
|
||||
// 从 localStorage 加载搜索历史
|
||||
const loadSearchHistory = () => {
|
||||
const history = localStorage.getItem('searchHistory');
|
||||
searchHistory.value = history ? JSON.parse(history) : [];
|
||||
};
|
||||
|
||||
// 保存搜索历史
|
||||
const saveSearchHistory = (keyword: string) => {
|
||||
if (!keyword) return;
|
||||
const history = searchHistory.value;
|
||||
// 移除重复的关键词
|
||||
const index = history.indexOf(keyword);
|
||||
if (index > -1) {
|
||||
history.splice(index, 1);
|
||||
}
|
||||
// 添加到开头
|
||||
history.unshift(keyword);
|
||||
// 只保留最近的20条记录
|
||||
if (history.length > 20) {
|
||||
history.pop();
|
||||
}
|
||||
searchHistory.value = history;
|
||||
localStorage.setItem('searchHistory', JSON.stringify(history));
|
||||
};
|
||||
|
||||
// 清空搜索历史
|
||||
const clearSearchHistory = () => {
|
||||
searchHistory.value = [];
|
||||
localStorage.removeItem('searchHistory');
|
||||
};
|
||||
|
||||
// 删除搜索历史
|
||||
const handleCloseSearchHistory = (keyword: string) => {
|
||||
searchHistory.value = searchHistory.value.filter((item) => item !== keyword);
|
||||
localStorage.setItem('searchHistory', JSON.stringify(searchHistory.value));
|
||||
};
|
||||
|
||||
// 热搜列表
|
||||
const hotSearchData = ref<IHotSearch>();
|
||||
@@ -92,6 +179,7 @@ const loadHotSearch = async () => {
|
||||
|
||||
onMounted(() => {
|
||||
loadHotSearch();
|
||||
loadSearchHistory();
|
||||
loadSearch(route.query.keyword);
|
||||
});
|
||||
|
||||
@@ -114,48 +202,103 @@ watch(
|
||||
);
|
||||
|
||||
const dateFormat = (time: any) => useDateFormat(time, 'YYYY.MM.DD').value;
|
||||
const loadSearch = async (keywords: any, type: any = null) => {
|
||||
hotKeyword.value = keywords;
|
||||
searchDetail.value = undefined;
|
||||
const loadSearch = async (keywords: any, type: any = null, isLoadMore = false) => {
|
||||
if (!keywords) return;
|
||||
|
||||
searchDetailLoading.value = true;
|
||||
const { data } = await getSearch({ keywords, type: type || searchType.value });
|
||||
if (!isLoadMore) {
|
||||
hotKeyword.value = keywords;
|
||||
searchDetail.value = undefined;
|
||||
page.value = 0;
|
||||
hasMore.value = true;
|
||||
currentKeyword.value = keywords;
|
||||
} else if (isLoadingMore.value || !hasMore.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
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'
|
||||
}));
|
||||
// 保存搜索历史
|
||||
if (!isLoadMore) {
|
||||
saveSearchHistory(keywords);
|
||||
}
|
||||
|
||||
const playlists = (data.result.playlists || []).map((item: any) => ({
|
||||
...item,
|
||||
picUrl: item.coverImgUrl,
|
||||
playCount: item.playCount,
|
||||
desc: item.creator.nickname,
|
||||
type: 'playlist'
|
||||
}));
|
||||
if (isLoadMore) {
|
||||
isLoadingMore.value = true;
|
||||
} else {
|
||||
searchDetailLoading.value = true;
|
||||
}
|
||||
|
||||
// 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
|
||||
};
|
||||
try {
|
||||
const { data } = await getSearch({
|
||||
keywords: currentKeyword.value,
|
||||
type: type || searchType.value,
|
||||
limit: ITEMS_PER_PAGE,
|
||||
offset: page.value * ITEMS_PER_PAGE
|
||||
});
|
||||
|
||||
searchDetailLoading.value = false;
|
||||
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)}`;
|
||||
});
|
||||
|
||||
if (isLoadMore && searchDetail.value) {
|
||||
// 合并数据
|
||||
searchDetail.value.songs = [...searchDetail.value.songs, ...songs];
|
||||
searchDetail.value.albums = [...searchDetail.value.albums, ...albums];
|
||||
searchDetail.value.mvs = [...searchDetail.value.mvs, ...mvs];
|
||||
searchDetail.value.playlists = [...searchDetail.value.playlists, ...playlists];
|
||||
} else {
|
||||
searchDetail.value = {
|
||||
songs,
|
||||
albums,
|
||||
mvs,
|
||||
playlists
|
||||
};
|
||||
}
|
||||
|
||||
// 判断是否还有更多数据
|
||||
hasMore.value =
|
||||
songs.length === ITEMS_PER_PAGE ||
|
||||
albums.length === ITEMS_PER_PAGE ||
|
||||
mvs.length === ITEMS_PER_PAGE ||
|
||||
playlists.length === ITEMS_PER_PAGE;
|
||||
|
||||
page.value++;
|
||||
} catch (error) {
|
||||
console.error('搜索失败:', error);
|
||||
} finally {
|
||||
searchDetailLoading.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) {
|
||||
loadSearch(currentKeyword.value, null, true);
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
@@ -171,6 +314,11 @@ const handlePlay = () => {
|
||||
const tracks = searchDetail.value?.songs || [];
|
||||
store.commit('setPlayList', tracks);
|
||||
};
|
||||
|
||||
// 点击搜索历史
|
||||
const handleSearchHistory = (keyword: string) => {
|
||||
loadSearch(keyword, 1);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -225,9 +373,35 @@ const handlePlay = () => {
|
||||
@apply text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.search-history {
|
||||
&-header {
|
||||
@apply flex justify-between items-center mb-4;
|
||||
@apply text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
&-list {
|
||||
@apply flex flex-wrap gap-2 px-4;
|
||||
}
|
||||
|
||||
&-item {
|
||||
@apply cursor-pointer;
|
||||
animation-duration: 0.2s;
|
||||
}
|
||||
}
|
||||
|
||||
.mobile {
|
||||
.hot-search {
|
||||
@apply mr-0 w-full;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -32,7 +32,15 @@
|
||||
<div class="set-item">
|
||||
<div>
|
||||
<div class="set-item-title">动画速度</div>
|
||||
<div class="set-item-content">调节动画播放速度</div>
|
||||
<div class="set-item-content">
|
||||
<div class="flex items-center gap-2">
|
||||
<n-switch v-model:value="setData.noAnimate">
|
||||
<template #checked>关闭</template>
|
||||
<template #unchecked>开启</template>
|
||||
</n-switch>
|
||||
<span>是否开启动画</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-400">{{ setData.animationSpeed }}x</span>
|
||||
|
||||
Reference in New Issue
Block a user