mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-04-24 16:27:23 +08:00
✨ feat: 优化音乐解析,添加搜索记录 添加搜索滚动加载更多 添加关闭动画功能
This commit is contained in:
@@ -20,3 +20,5 @@ bun.lockb
|
|||||||
.env.*.local
|
.env.*.local
|
||||||
|
|
||||||
out
|
out
|
||||||
|
|
||||||
|
.cursorrules
|
||||||
+6
-5
@@ -24,17 +24,18 @@ let mainWindow: Electron.BrowserWindow;
|
|||||||
|
|
||||||
// 初始化应用
|
// 初始化应用
|
||||||
function initialize() {
|
function initialize() {
|
||||||
// 初始化各个模块
|
// 初始化配置管理
|
||||||
initializeConfig();
|
initializeConfig();
|
||||||
initializeFileManager();
|
// 初始化缓存管理
|
||||||
initializeCacheManager();
|
initializeCacheManager();
|
||||||
|
// 初始化文件管理
|
||||||
|
initializeFileManager();
|
||||||
|
// 初始化窗口管理
|
||||||
|
initializeWindowManager();
|
||||||
|
|
||||||
// 创建主窗口
|
// 创建主窗口
|
||||||
mainWindow = createMainWindow(icon);
|
mainWindow = createMainWindow(icon);
|
||||||
|
|
||||||
// 初始化窗口管理
|
|
||||||
initializeWindowManager();
|
|
||||||
|
|
||||||
// 初始化托盘
|
// 初始化托盘
|
||||||
initializeTray(iconPath, mainWindow);
|
initializeTray(iconPath, mainWindow);
|
||||||
|
|
||||||
|
|||||||
+152
-13
@@ -1,23 +1,162 @@
|
|||||||
import match from '@unblockneteasemusic/server';
|
import match from '@unblockneteasemusic/server';
|
||||||
|
import Store from 'electron-store';
|
||||||
|
|
||||||
const unblockMusic = async (id: any, songData: any): Promise<any> => {
|
type Platform = 'qq' | 'migu' | 'kugou' | 'pyncmd' | 'joox' | 'kuwo' | 'bilibili' | 'youtube';
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
match(parseInt(id, 10), ['qq', 'migu', 'kugou', 'joox'], songData)
|
interface SongData {
|
||||||
.then((data) => {
|
name: string;
|
||||||
resolve({
|
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: {
|
||||||
data,
|
data: ResponseData;
|
||||||
params: {
|
params: {
|
||||||
id,
|
id: number;
|
||||||
type: 'song'
|
type: 'song';
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CacheData extends UnblockResult {
|
||||||
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CacheStore {
|
||||||
|
[key: string]: CacheData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化缓存存储
|
||||||
|
const store = new Store<CacheStore>({
|
||||||
|
name: 'unblock-cache'
|
||||||
});
|
});
|
||||||
})
|
|
||||||
.catch((err) => {
|
// 缓存过期时间(24小时)
|
||||||
reject(err);
|
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 {
|
interface IParams {
|
||||||
keywords: string;
|
keywords: string;
|
||||||
type: number;
|
type: number;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
}
|
}
|
||||||
// 搜索内容
|
// 搜索内容
|
||||||
export const getSearch = (params: IParams) => {
|
export const getSearch = (params: IParams) => {
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ export const setAnimationClass = (type: String) => {
|
|||||||
};
|
};
|
||||||
// 设置动画延时
|
// 设置动画延时
|
||||||
export const setAnimationDelay = (index: number = 6, time: number = 50) => {
|
export const setAnimationDelay = (index: number = 6, time: number = 50) => {
|
||||||
|
if (store.state.setData?.noAnimate) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
const speed = store.state.setData?.animationSpeed || 1;
|
const speed = store.state.setData?.animationSpeed || 1;
|
||||||
return `animation-delay:${(index * time) / (speed * 2)}ms`;
|
return `animation-delay:${(index * time) / (speed * 2)}ms`;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -29,8 +29,15 @@
|
|||||||
class="search-list"
|
class="search-list"
|
||||||
:class="setAnimationClass('animate__fadeInUp')"
|
:class="setAnimationClass('animate__fadeInUp')"
|
||||||
:native-scrollbar="false"
|
: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">
|
<div v-loading="searchDetailLoading" class="search-list-box">
|
||||||
<template v-if="searchDetail">
|
<template v-if="searchDetail">
|
||||||
<div
|
<div
|
||||||
@@ -53,6 +60,41 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</n-layout>
|
</n-layout>
|
||||||
@@ -82,6 +124,51 @@ const store = useStore();
|
|||||||
const searchDetail = ref<any>();
|
const searchDetail = ref<any>();
|
||||||
const searchType = computed(() => store.state.searchType as number);
|
const searchType = computed(() => store.state.searchType as number);
|
||||||
const searchDetailLoading = ref(false);
|
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>();
|
const hotSearchData = ref<IHotSearch>();
|
||||||
@@ -92,6 +179,7 @@ const loadHotSearch = async () => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadHotSearch();
|
loadHotSearch();
|
||||||
|
loadSearchHistory();
|
||||||
loadSearch(route.query.keyword);
|
loadSearch(route.query.keyword);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -114,13 +202,37 @@ watch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const dateFormat = (time: any) => useDateFormat(time, 'YYYY.MM.DD').value;
|
const dateFormat = (time: any) => useDateFormat(time, 'YYYY.MM.DD').value;
|
||||||
const loadSearch = async (keywords: any, type: any = null) => {
|
const loadSearch = async (keywords: any, type: any = null, isLoadMore = false) => {
|
||||||
hotKeyword.value = keywords;
|
|
||||||
searchDetail.value = undefined;
|
|
||||||
if (!keywords) return;
|
if (!keywords) return;
|
||||||
|
|
||||||
|
if (!isLoadMore) {
|
||||||
|
hotKeyword.value = keywords;
|
||||||
|
searchDetail.value = undefined;
|
||||||
|
page.value = 0;
|
||||||
|
hasMore.value = true;
|
||||||
|
currentKeyword.value = keywords;
|
||||||
|
} else if (isLoadingMore.value || !hasMore.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存搜索历史
|
||||||
|
if (!isLoadMore) {
|
||||||
|
saveSearchHistory(keywords);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoadMore) {
|
||||||
|
isLoadingMore.value = true;
|
||||||
|
} else {
|
||||||
searchDetailLoading.value = true;
|
searchDetailLoading.value = true;
|
||||||
const { data } = await getSearch({ keywords, type: type || searchType.value });
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data } = await getSearch({
|
||||||
|
keywords: currentKeyword.value,
|
||||||
|
type: type || searchType.value,
|
||||||
|
limit: ITEMS_PER_PAGE,
|
||||||
|
offset: page.value * ITEMS_PER_PAGE
|
||||||
|
});
|
||||||
|
|
||||||
const songs = data.result.songs || [];
|
const songs = data.result.songs || [];
|
||||||
const albums = data.result.albums || [];
|
const albums = data.result.albums || [];
|
||||||
@@ -148,14 +260,45 @@ const loadSearch = async (keywords: any, type: any = null) => {
|
|||||||
albums.forEach((item: any) => {
|
albums.forEach((item: any) => {
|
||||||
item.desc = `${item.artist.name} ${item.company} ${dateFormat(item.publishTime)}`;
|
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 = {
|
searchDetail.value = {
|
||||||
songs,
|
songs,
|
||||||
albums,
|
albums,
|
||||||
mvs,
|
mvs,
|
||||||
playlists
|
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;
|
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(
|
watch(
|
||||||
@@ -171,6 +314,11 @@ const handlePlay = () => {
|
|||||||
const tracks = searchDetail.value?.songs || [];
|
const tracks = searchDetail.value?.songs || [];
|
||||||
store.commit('setPlayList', tracks);
|
store.commit('setPlayList', tracks);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 点击搜索历史
|
||||||
|
const handleSearchHistory = (keyword: string) => {
|
||||||
|
loadSearch(keyword, 1);
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -225,9 +373,35 @@ const handlePlay = () => {
|
|||||||
@apply text-gray-900 dark:text-white;
|
@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 {
|
.mobile {
|
||||||
.hot-search {
|
.hot-search {
|
||||||
@apply mr-0 w-full;
|
@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>
|
</style>
|
||||||
|
|||||||
@@ -32,7 +32,15 @@
|
|||||||
<div class="set-item">
|
<div class="set-item">
|
||||||
<div>
|
<div>
|
||||||
<div class="set-item-title">动画速度</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>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-sm text-gray-400">{{ setData.animationSpeed }}x</span>
|
<span class="text-sm text-gray-400">{{ setData.animationSpeed }}x</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user