diff --git a/electron/main/apis/netease/index.ts b/electron/main/apis/netease/index.ts index 5509d9cae..2fa432d16 100644 --- a/electron/main/apis/netease/index.ts +++ b/electron/main/apis/netease/index.ts @@ -52,6 +52,7 @@ const NON_CACHEABLE: ReadonlySet = new Set([ "register_anonimous", "song_url", "song_download_url", + "song_dynamic_cover", "scrobble", "scrobble_v1", "like", diff --git a/electron/main/apis/netease/modules/index.ts b/electron/main/apis/netease/modules/index.ts index 6dd5f3748..2ec2dd58a 100644 --- a/electron/main/apis/netease/modules/index.ts +++ b/electron/main/apis/netease/modules/index.ts @@ -66,6 +66,7 @@ import song_url from "./song_url"; import song_download_url from "./song_download_url"; import song_simi_get from "./song_simi_get"; import song_wiki_info from "./song_wiki_info"; +import song_dynamic_cover from "./song_dynamic_cover"; import playmode_intelligence from "./playmode_intelligence"; import personal_fm from "./personal_fm"; import fm_trash from "./fm_trash"; @@ -161,6 +162,7 @@ export const modules: Record = { song_download_url, song_simi_get, song_wiki_info, + song_dynamic_cover, playmode_intelligence, personal_fm, personal_fm_mode: personal_fm, diff --git a/electron/main/apis/netease/modules/song_dynamic_cover.ts b/electron/main/apis/netease/modules/song_dynamic_cover.ts new file mode 100644 index 000000000..1eedf25dd --- /dev/null +++ b/electron/main/apis/netease/modules/song_dynamic_cover.ts @@ -0,0 +1,18 @@ +/** + * 获取歌曲动态封面 + * + * params: + * - id 歌曲 id + * + * 响应:`{ code, data: { videoPlayUrl, ... } }` + */ + +import { createOption } from "../core/option"; +import type { NeteaseModule } from "../core/types"; + +const song_dynamic_cover: NeteaseModule = (query, request) => { + const data = { songId: query.id }; + return request("/api/songplay/dynamic-cover", data, createOption(query)); +}; + +export default song_dynamic_cover; diff --git a/src/apis/song/netease.ts b/src/apis/song/netease.ts index ec2910c87..34d632128 100644 --- a/src/apis/song/netease.ts +++ b/src/apis/song/netease.ts @@ -5,6 +5,20 @@ import { netease as neteaseApi, neteaseCall } from "@/apis/netease"; import { isExplicitNeteaseAuthFailure } from "@/apis/neteaseAuth"; import { songsToTracks } from "@/utils/format/netease"; +/** + * 获取歌曲动态封面 + * @param id - 平台 songId + * @returns videoPlayUrl 或 null + */ +export const getDynamicCover = async (id: string): Promise => { + try { + const body = await neteaseCall("song_dynamic_cover", { id }); + return body?.data?.videoPlayUrl ?? null; + } catch { + return null; + } +}; + /** * 按 ID 批量取歌曲详情 * @param ids - 平台 songId 列表 diff --git a/src/components/player/FullPlayer/PlayerCover.vue b/src/components/player/FullPlayer/PlayerCover.vue index b19c4b80e..02211aa8d 100644 --- a/src/components/player/FullPlayer/PlayerCover.vue +++ b/src/components/player/FullPlayer/PlayerCover.vue @@ -1,11 +1,18 @@ @@ -64,4 +175,33 @@ watchEffect(async () => { rgba(0, 0, 0, 0) 100% ); } + +.dynamic-cover { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: 32px; + overflow: hidden; + z-index: 1; + opacity: 0; + transition: opacity 0.8s ease-in-out; + backface-visibility: hidden; + transform: translateZ(0); + + &.loaded { + opacity: 1; + } +} + +.fade-enter-active, +.fade-leave-active { + transition: opacity 0.3s ease; +} + +.fade-enter-from, +.fade-leave-to { + opacity: 0; +} diff --git a/src/i18n/locales/en-US.json b/src/i18n/locales/en-US.json index 7ef148e56..913b1436c 100644 --- a/src/i18n/locales/en-US.json +++ b/src/i18n/locales/en-US.json @@ -880,6 +880,10 @@ "label": "Preload next track", "description": "Fetch the next track's playback info in advance, which may increase network usage" }, + "dynamicCover": { + "label": "Dynamic Cover", + "description": "Show animated covers for supported songs (cover mode only)" + }, "outputDevice": { "label": "Output Device", "description": "Select audio output device", diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index a01cd8e5b..da296a169 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -868,6 +868,10 @@ "label": "预载下一首歌曲", "description": "提前获取下一首的播放信息,可能会增加网络占用" }, + "dynamicCover": { + "label": "动态封面", + "description": "可展示部分歌曲的动态封面,仅在封面模式有效" + }, "outputDevice": { "label": "输出设备", "description": "选择音频输出设备", diff --git a/src/settings/categories/player.ts b/src/settings/categories/player.ts index b950f57e6..5283876a1 100644 --- a/src/settings/categories/player.ts +++ b/src/settings/categories/player.ts @@ -1,6 +1,7 @@ import type { SettingCategory } from "@/types/settings-schema"; import DeviceSelector from "@/components/settings/custom/DeviceSelector.vue"; import IconLucidePlay from "~icons/lucide/play"; +import { useUserStore } from "@/stores/user"; const playerCategory: SettingCategory = { id: "player", @@ -86,6 +87,13 @@ const playerCategory: SettingCategory = { binding: { store: "settings", path: "player.preloadNextTrack" }, defaultValue: false, }, + { + key: "dynamicCover", + type: "switch", + binding: { store: "settings", path: "player.dynamicCover" }, + defaultValue: false, + disabled: () => !useUserStore().isLoggedIn, + }, ], }, { diff --git a/src/stores/settings.ts b/src/stores/settings.ts index 7efd5cf31..98c1fbe43 100644 --- a/src/stores/settings.ts +++ b/src/stores/settings.ts @@ -152,6 +152,7 @@ export const useSettingsStore = defineStore( showLyricInBar: true, preloadNextTrack: false, searchPlayBehavior: "current", + dynamicCover: false, }); /** 强迫症设置 */ diff --git a/src/types/settings.ts b/src/types/settings.ts index dbe02fb85..635253f9a 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -259,6 +259,8 @@ export interface PlayerSettings { preloadNextTrack: boolean; /** 搜索页播放行为 */ searchPlayBehavior: SearchPlayBehavior; + /** 是否展示动态封面(仅网易云来源,封面模式有效) */ + dynamicCover: boolean; } /** 外观设置 */