mirror of
https://github.com/imsyy/SPlayer.git
synced 2025-11-25 11:29:26 +08:00
✨ feat: 新增 每日推荐 - 不感兴趣
This commit is contained in:
@@ -12,6 +12,16 @@ export const dailyRecommend = (type: "songs" | "resource" = "songs") => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 每日推荐 - 不感兴趣
|
||||||
|
*/
|
||||||
|
export const dailyRecommendDislike = (id: number) => {
|
||||||
|
return request({
|
||||||
|
url: "/recommend/songs/dislike",
|
||||||
|
params: { id, timestamp: Date.now() },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 推荐内容
|
* 推荐内容
|
||||||
* @param {string} [type] - 推荐类型
|
* @param {string} [type] - 推荐类型
|
||||||
|
|||||||
@@ -71,16 +71,14 @@ const url = `https://amll-ttml-db.stevexmh.net/ncm/${id}`;
|
|||||||
try {
|
try {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (response === null || response.status !== 200) {
|
if (response === null || response.status !== 200) {
|
||||||
console.error(`TTML API请求失败或TTML仓库没有歌词, 将会使用默认歌词`);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const data = await response.text();
|
const data = await response.text();
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error('TTML API请求出错:', error);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取歌曲下载链接
|
* 获取歌曲下载链接
|
||||||
|
|||||||
1
src/assets/icons/HeartBroken.svg
Normal file
1
src/assets/icons/HeartBroken.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Material Symbols by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="currentColor" d="M12.025 20.35q-.35 0-.687-.125t-.613-.375Q8 17.45 6.3 15.812t-2.662-2.874t-1.3-2.263T2 8.5q0-2.3 1.6-3.9T7.5 3q1.65 0 2.9.637t.9 1.838l-.925 3.25q-.125.5.163.888t.787.387H13l-.65 6.35q-.025.2.163.225t.237-.15L14.6 10.3q.15-.5-.15-.9t-.8-.4H12l1.525-4.525Q13.8 3.6 14.675 3.3T16.5 3q2.3 0 3.9 1.6T22 8.5q0 1.1-.4 2.175t-1.388 2.375t-2.65 2.938t-4.212 3.862q-.275.25-.625.375t-.7.125"/></svg>
|
||||||
|
After Width: | Height: | Size: 617 B |
@@ -217,9 +217,6 @@ const changeGlobalTheme = () => {
|
|||||||
railColor: toRGBA(primaryRGB, 0.2),
|
railColor: toRGBA(primaryRGB, 0.2),
|
||||||
railColorHover: toRGBA(primaryRGB, 0.3),
|
railColorHover: toRGBA(primaryRGB, 0.3),
|
||||||
},
|
},
|
||||||
Popover: {
|
|
||||||
color: `rgb(${surfaceContainerRGB})`,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<Transition name="fade" mode="out-in">
|
<Transition name="fade" mode="out-in">
|
||||||
<VirtList
|
<VirtList
|
||||||
ref="listRef"
|
ref="listRef"
|
||||||
:key="listData?.[0]?.id"
|
:key="listKey"
|
||||||
:list="listData"
|
:list="listData"
|
||||||
:minSize="94"
|
:minSize="94"
|
||||||
:buffer="2"
|
:buffer="2"
|
||||||
@@ -62,7 +62,15 @@
|
|||||||
:hiddenSize="hiddenSize"
|
:hiddenSize="hiddenSize"
|
||||||
@dblclick.stop="player.updatePlayList(listData, itemData, playListId)"
|
@dblclick.stop="player.updatePlayList(listData, itemData, playListId)"
|
||||||
@contextmenu.stop="
|
@contextmenu.stop="
|
||||||
songListMenuRef?.openDropdown($event, listData, itemData, index, type, playListId)
|
songListMenuRef?.openDropdown(
|
||||||
|
$event,
|
||||||
|
listData,
|
||||||
|
itemData,
|
||||||
|
index,
|
||||||
|
type,
|
||||||
|
playListId,
|
||||||
|
isDailyRecommend,
|
||||||
|
)
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
@@ -114,7 +122,7 @@ import type { DropdownOption } from "naive-ui";
|
|||||||
import type { SongType, SortType } from "@/types/main";
|
import type { SongType, SortType } from "@/types/main";
|
||||||
import { useMusicStore, useStatusStore } from "@/stores";
|
import { useMusicStore, useStatusStore } from "@/stores";
|
||||||
import { VirtList } from "vue-virt-list";
|
import { VirtList } from "vue-virt-list";
|
||||||
import { cloneDeep, entries, isEmpty } from "lodash-es";
|
import { entries, isEmpty } from "lodash-es";
|
||||||
import { sortOptions } from "@/utils/meta";
|
import { sortOptions } from "@/utils/meta";
|
||||||
import { renderIcon } from "@/utils/helper";
|
import { renderIcon } from "@/utils/helper";
|
||||||
import SongListMenu from "@/components/Menu/SongListMenu.vue";
|
import SongListMenu from "@/components/Menu/SongListMenu.vue";
|
||||||
@@ -143,11 +151,14 @@ const props = withDefaults(
|
|||||||
disabledSort?: boolean;
|
disabledSort?: boolean;
|
||||||
// 播放歌单 ID
|
// 播放歌单 ID
|
||||||
playListId?: number;
|
playListId?: number;
|
||||||
|
// 是否为每日推荐
|
||||||
|
isDailyRecommend?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
type: "song",
|
type: "song",
|
||||||
loadingText: "努力加载中...",
|
loadingText: "努力加载中...",
|
||||||
playListId: 0,
|
playListId: 0,
|
||||||
|
isDailyRecommend: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -179,8 +190,9 @@ const songListMenuRef = ref<InstanceType<typeof SongListMenu> | null>(null);
|
|||||||
|
|
||||||
// 列表数据
|
// 列表数据
|
||||||
const listData = computed<SongType[]>(() => {
|
const listData = computed<SongType[]>(() => {
|
||||||
const data = cloneDeep(props.data);
|
if (props.disabledSort) return props.data;
|
||||||
if (props.disabledSort) return data;
|
// 创建副本用于排序(避免修改原数组)
|
||||||
|
const data = [...props.data];
|
||||||
// 排序
|
// 排序
|
||||||
switch (statusStore.listSort) {
|
switch (statusStore.listSort) {
|
||||||
case "titleAZ":
|
case "titleAZ":
|
||||||
@@ -212,6 +224,16 @@ const listData = computed<SongType[]>(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 虚拟列表 key
|
||||||
|
const listKey = computed(() => {
|
||||||
|
// 每日推荐
|
||||||
|
if (props.isDailyRecommend) {
|
||||||
|
return musicStore.dailySongsData.timestamp || 0;
|
||||||
|
}
|
||||||
|
// 其他列表长度(检测增删操作)
|
||||||
|
return listData.value?.length || 0;
|
||||||
|
});
|
||||||
|
|
||||||
// 列表是否具有播放歌曲
|
// 列表是否具有播放歌曲
|
||||||
const hasPlaySong = computed(() => {
|
const hasPlaySong = computed(() => {
|
||||||
return listData.value.findIndex((item) => item.id === musicStore.playSong.id);
|
return listData.value.findIndex((item) => item.id === musicStore.playSong.id);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { SongType } from "@/types/main";
|
import type { SongType } from "@/types/main";
|
||||||
import { NAlert, type DropdownOption } from "naive-ui";
|
import { NAlert, type DropdownOption } from "naive-ui";
|
||||||
import { useStatusStore, useLocalStore, useDataStore } from "@/stores";
|
import { useStatusStore, useLocalStore, useDataStore, useMusicStore } from "@/stores";
|
||||||
import { renderIcon, copyData } from "@/utils/helper";
|
import { renderIcon, copyData } from "@/utils/helper";
|
||||||
import { deleteCloudSong, importCloudSong } from "@/api/cloud";
|
import { deleteCloudSong, importCloudSong } from "@/api/cloud";
|
||||||
import {
|
import {
|
||||||
@@ -29,6 +29,8 @@ import {
|
|||||||
} from "@/utils/modal";
|
} from "@/utils/modal";
|
||||||
import { deleteSongs, isLogin } from "@/utils/auth";
|
import { deleteSongs, isLogin } from "@/utils/auth";
|
||||||
import { songUrl } from "@/api/song";
|
import { songUrl } from "@/api/song";
|
||||||
|
import { dailyRecommendDislike } from "@/api/rec";
|
||||||
|
import { formatSongsList } from "@/utils/format";
|
||||||
import player from "@/utils/player";
|
import player from "@/utils/player";
|
||||||
|
|
||||||
const emit = defineEmits<{ removeSong: [index: number[]] }>();
|
const emit = defineEmits<{ removeSong: [index: number[]] }>();
|
||||||
@@ -37,6 +39,7 @@ const router = useRouter();
|
|||||||
const dataStore = useDataStore();
|
const dataStore = useDataStore();
|
||||||
const localStore = useLocalStore();
|
const localStore = useLocalStore();
|
||||||
const statusStore = useStatusStore();
|
const statusStore = useStatusStore();
|
||||||
|
const musicStore = useMusicStore();
|
||||||
|
|
||||||
// 右键菜单数据
|
// 右键菜单数据
|
||||||
const dropdownX = ref<number>(0);
|
const dropdownX = ref<number>(0);
|
||||||
@@ -52,6 +55,7 @@ const openDropdown = (
|
|||||||
index: number,
|
index: number,
|
||||||
type: "song" | "radio",
|
type: "song" | "radio",
|
||||||
playListId?: number,
|
playListId?: number,
|
||||||
|
isDailyRecommend: boolean = false,
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -110,6 +114,15 @@ const openDropdown = (
|
|||||||
key: "line-1",
|
key: "line-1",
|
||||||
type: "divider",
|
type: "divider",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "dislike",
|
||||||
|
label: "不感兴趣",
|
||||||
|
show: isDailyRecommend && isLoginNormal,
|
||||||
|
props: {
|
||||||
|
onClick: () => dislikeSong(song, index),
|
||||||
|
},
|
||||||
|
icon: renderIcon("HeartBroken"),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "more",
|
key: "more",
|
||||||
label: "更多操作",
|
label: "更多操作",
|
||||||
@@ -322,6 +335,41 @@ const importSongToCloud = async (song: SongType) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 每日推荐 - 不感兴趣
|
||||||
|
const dislikeSong = async (song: SongType, index: number) => {
|
||||||
|
if (!song?.id) return;
|
||||||
|
const loadingMessage = window.$message.loading("正在不感兴趣...", { duration: 0 });
|
||||||
|
try {
|
||||||
|
const result = await dailyRecommendDislike(song.id);
|
||||||
|
// 关闭 loading
|
||||||
|
loadingMessage.destroy();
|
||||||
|
if (result.code === 200) {
|
||||||
|
// 创建新数组以触发响应式更新
|
||||||
|
const currentList = [...musicStore.dailySongsData.list];
|
||||||
|
// 从列表中移除当前歌曲
|
||||||
|
currentList.splice(index, 1);
|
||||||
|
// 替换原歌曲
|
||||||
|
if (result.data) {
|
||||||
|
const formattedSong = formatSongsList([result.data])[0];
|
||||||
|
currentList.splice(index, 0, formattedSong);
|
||||||
|
}
|
||||||
|
// 更新列表(同时更新 timestamp 触发完整响应式更新)
|
||||||
|
musicStore.dailySongsData = {
|
||||||
|
list: currentList,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
};
|
||||||
|
window.$message.success("已标记为不感兴趣");
|
||||||
|
} else {
|
||||||
|
window.$message.error("操作失败,请重试");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 关闭 loading
|
||||||
|
loadingMessage.destroy();
|
||||||
|
window.$message.error("操作失败,请重试");
|
||||||
|
console.error("不感兴趣操作失败:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
defineExpose({ openDropdown });
|
defineExpose({ openDropdown });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
:max="1"
|
:max="1"
|
||||||
:step="0.01"
|
:step="0.01"
|
||||||
vertical
|
vertical
|
||||||
@update:value="(val) => player.setVolume(val)"
|
@update:value="(val: number) => player.setVolume(val)"
|
||||||
/>
|
/>
|
||||||
<n-text class="slider-num">{{ statusStore.playVolumePercent }}%</n-text>
|
<n-text class="slider-num">{{ statusStore.playVolumePercent }}%</n-text>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ interface StatusState {
|
|||||||
spectrumsData: number[];
|
spectrumsData: number[];
|
||||||
/** 纯净歌词模式 */
|
/** 纯净歌词模式 */
|
||||||
pureLyricMode: boolean;
|
pureLyricMode: boolean;
|
||||||
|
/** 是否使用 TTML 歌词 */
|
||||||
|
usingTTMLLyric: boolean;
|
||||||
/** 当前播放索引 */
|
/** 当前播放索引 */
|
||||||
playIndex: number;
|
playIndex: number;
|
||||||
/** 歌词播放索引 */
|
/** 歌词播放索引 */
|
||||||
@@ -110,6 +112,7 @@ export const useStatusStore = defineStore("status", {
|
|||||||
currentTimeOffsetMap: {},
|
currentTimeOffsetMap: {},
|
||||||
songCoverTheme: {},
|
songCoverTheme: {},
|
||||||
pureLyricMode: false,
|
pureLyricMode: false,
|
||||||
|
usingTTMLLyric: false,
|
||||||
spectrumsData: [],
|
spectrumsData: [],
|
||||||
playIndex: -1,
|
playIndex: -1,
|
||||||
lyricIndex: -1,
|
lyricIndex: -1,
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ const getExcludeKeywords = () => {
|
|||||||
// 恢复默认
|
// 恢复默认
|
||||||
export const resetSongLyric = () => {
|
export const resetSongLyric = () => {
|
||||||
const musicStore = useMusicStore();
|
const musicStore = useMusicStore();
|
||||||
|
const statusStore = useStatusStore();
|
||||||
musicStore.songLyric = {
|
musicStore.songLyric = {
|
||||||
lrcData: [],
|
lrcData: [],
|
||||||
lrcAMData: [],
|
lrcAMData: [],
|
||||||
yrcData: [],
|
yrcData: [],
|
||||||
yrcAMData: [],
|
yrcAMData: [],
|
||||||
};
|
};
|
||||||
|
statusStore.usingTTMLLyric = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 解析歌词数据
|
// 解析歌词数据
|
||||||
@@ -172,21 +174,30 @@ export const alignAMLyrics = (
|
|||||||
|
|
||||||
// 处理本地歌词
|
// 处理本地歌词
|
||||||
export const parseLocalLyric = (lyric: string, format: "lrc" | "ttml") => {
|
export const parseLocalLyric = (lyric: string, format: "lrc" | "ttml") => {
|
||||||
|
const statusStore = useStatusStore();
|
||||||
|
|
||||||
if (!lyric) {
|
if (!lyric) {
|
||||||
resetSongLyric();
|
resetSongLyric();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const musicStore = useMusicStore();
|
|
||||||
switch (format) {
|
switch (format) {
|
||||||
case "lrc":
|
case "lrc":
|
||||||
parseLocalLyricLrc(lyric, musicStore);
|
parseLocalLyricLrc(lyric);
|
||||||
|
statusStore.usingTTMLLyric = false;
|
||||||
break;
|
break;
|
||||||
case "ttml":
|
case "ttml":
|
||||||
parseLocalLyricAM(lyric, musicStore);
|
parseLocalLyricAM(lyric);
|
||||||
|
statusStore.usingTTMLLyric = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const parseLocalLyricLrc = (lyric: string, musicStore: any) => {
|
|
||||||
|
/**
|
||||||
|
* 解析本地LRC歌词
|
||||||
|
* @param lyric LRC格式的歌词内容
|
||||||
|
*/
|
||||||
|
const parseLocalLyricLrc = (lyric: string) => {
|
||||||
|
const musicStore = useMusicStore();
|
||||||
// 解析
|
// 解析
|
||||||
const lrc: LyricLine[] = parseLrc(lyric);
|
const lrc: LyricLine[] = parseLrc(lyric);
|
||||||
const lrcData: LyricType[] = parseLrcData(lrc);
|
const lrcData: LyricType[] = parseLrcData(lrc);
|
||||||
@@ -220,7 +231,13 @@ const parseLocalLyricLrc = (lyric: string, musicStore: any) => {
|
|||||||
yrcAMData: [],
|
yrcAMData: [],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const parseLocalLyricAM = (lyric: string, musicStore: any) => {
|
|
||||||
|
/**
|
||||||
|
* 解析本地AM歌词
|
||||||
|
* @param lyric AM格式的歌词内容
|
||||||
|
*/
|
||||||
|
const parseLocalLyricAM = (lyric: string) => {
|
||||||
|
const musicStore = useMusicStore();
|
||||||
const ttml = parseTTML(lyric);
|
const ttml = parseTTML(lyric);
|
||||||
const yrcAMData = parseTTMLToAMLL(ttml);
|
const yrcAMData = parseTTMLToAMLL(ttml);
|
||||||
const yrcData = parseTTMLToYrc(ttml);
|
const yrcData = parseTTMLToYrc(ttml);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMusicStore, useSettingStore } from "@/stores";
|
import { useMusicStore, useSettingStore, useStatusStore } from "@/stores";
|
||||||
import { parsedLyricsData, parseTTMLToAMLL, parseTTMLToYrc, resetSongLyric } from "../lyric";
|
import { parsedLyricsData, parseTTMLToAMLL, parseTTMLToYrc, resetSongLyric } from "../lyric";
|
||||||
import { songLyric, songLyricTTML } from "@/api/song";
|
import { songLyric, songLyricTTML } from "@/api/song";
|
||||||
import { parseTTML } from "@applemusic-like-lyrics/lyric";
|
import { parseTTML } from "@applemusic-like-lyrics/lyric";
|
||||||
@@ -10,14 +10,17 @@ import { LyricType } from "@/types/main";
|
|||||||
* @param id 歌曲id
|
* @param id 歌曲id
|
||||||
*/
|
*/
|
||||||
export const getLyricData = async (id: number) => {
|
export const getLyricData = async (id: number) => {
|
||||||
|
const musicStore = useMusicStore();
|
||||||
|
const settingStore = useSettingStore();
|
||||||
|
const statusStore = useStatusStore();
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
|
statusStore.usingTTMLLyric = false;
|
||||||
resetSongLyric();
|
resetSongLyric();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const musicStore = useMusicStore();
|
|
||||||
const settingStore = useSettingStore();
|
|
||||||
// 检测本地歌词覆盖
|
// 检测本地歌词覆盖
|
||||||
const getLyric = getLyricFun(settingStore.localLyricPath, id);
|
const getLyric = getLyricFun(settingStore.localLyricPath, id);
|
||||||
const [lyricRes, ttmlContent] = await Promise.all([
|
const [lyricRes, ttmlContent] = await Promise.all([
|
||||||
@@ -27,7 +30,10 @@ export const getLyricData = async (id: number) => {
|
|||||||
parsedLyricsData(lyricRes);
|
parsedLyricsData(lyricRes);
|
||||||
if (ttmlContent) {
|
if (ttmlContent) {
|
||||||
const parsedResult = parseTTML(ttmlContent);
|
const parsedResult = parseTTML(ttmlContent);
|
||||||
if (!parsedResult?.lines?.length) return;
|
if (!parsedResult?.lines?.length) {
|
||||||
|
statusStore.usingTTMLLyric = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const ttmlLyric = parseTTMLToAMLL(parsedResult);
|
const ttmlLyric = parseTTMLToAMLL(parsedResult);
|
||||||
const ttmlYrcLyric = parseTTMLToYrc(parsedResult);
|
const ttmlYrcLyric = parseTTMLToYrc(parsedResult);
|
||||||
console.log("TTML lyrics:", ttmlLyric, ttmlYrcLyric);
|
console.log("TTML lyrics:", ttmlLyric, ttmlYrcLyric);
|
||||||
@@ -46,10 +52,16 @@ export const getLyricData = async (id: number) => {
|
|||||||
...musicStore.songLyric,
|
...musicStore.songLyric,
|
||||||
...updates,
|
...updates,
|
||||||
};
|
};
|
||||||
|
statusStore.usingTTMLLyric = true;
|
||||||
|
} else {
|
||||||
|
statusStore.usingTTMLLyric = false;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
statusStore.usingTTMLLyric = false;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ Error loading lyrics:", error);
|
console.error("❌ Error loading lyrics:", error);
|
||||||
|
statusStore.usingTTMLLyric = false;
|
||||||
resetSongLyric();
|
resetSongLyric();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,7 +37,13 @@
|
|||||||
</n-flex>
|
</n-flex>
|
||||||
</div>
|
</div>
|
||||||
<!-- 列表 -->
|
<!-- 列表 -->
|
||||||
<SongList :data="musicStore.dailySongsData.list" :loading="true" height="auto" />
|
<SongList
|
||||||
|
:data="musicStore.dailySongsData.list"
|
||||||
|
:loading="true"
|
||||||
|
height="auto"
|
||||||
|
isDailyRecommend
|
||||||
|
disabledSort
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user