Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions electron/main/apis/netease/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const NON_CACHEABLE: ReadonlySet<string> = new Set([
"register_anonimous",
"song_url",
"song_download_url",
"song_dynamic_cover",
"scrobble",
"scrobble_v1",
"like",
Expand Down
2 changes: 2 additions & 0 deletions electron/main/apis/netease/modules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -161,6 +162,7 @@ export const modules: Record<string, NeteaseModule> = {
song_download_url,
song_simi_get,
song_wiki_info,
song_dynamic_cover,
playmode_intelligence,
personal_fm,
personal_fm_mode: personal_fm,
Expand Down
18 changes: 18 additions & 0 deletions electron/main/apis/netease/modules/song_dynamic_cover.ts
Original file line number Diff line number Diff line change
@@ -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;
14 changes: 14 additions & 0 deletions src/apis/song/netease.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null> => {
try {
const body = await neteaseCall("song_dynamic_cover", { id });
return body?.data?.videoPlayUrl ?? null;
} catch {
return null;
}
};

/**
* 按 ID 批量取歌曲详情
* @param ids - 平台 songId 列表
Expand Down
140 changes: 140 additions & 0 deletions src/components/player/FullPlayer/PlayerCover.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
<script setup lang="ts">
import { getDynamicCover } from "@/apis/song/netease";
import { useMediaStore } from "@/stores/media";
import { useStatusStore } from "@/stores/status";
import { useSettingsStore } from "@/stores/settings";
import { useUserStore } from "@/stores/user";
import { useTimeoutFn } from "@vueuse/core";
import { nextTick } from "vue";

withDefaults(defineProps<{ fullscreen?: boolean }>(), { fullscreen: false });

const media = useMediaStore();
const status = useStatusStore();
const settings = useSettingsStore();
const user = useUserStore();
const { isPlaying } = storeToRefs(status);

/** 加载中的歌曲使用队列当前项作为封面兜底。 */
Expand All @@ -28,6 +35,96 @@ watchEffect(async () => {
if (displayTrack.value?.id !== id || !r.success || !r.data) return;
hdCache.value = { id, data: r.data };
});

/** 动态封面实现 */
const dynamicCoverUrl = ref<string>("");
const dynamicCoverLoaded = ref<boolean>(false);
const videoRef = ref<HTMLVideoElement | null>(null);

/** 是否处于封面展示模式(非全屏) */
const isCoverMode = computed(() => settings.player.coverLayout !== "fullscreen");

/** 是否在加载中 */
const trackLoading = computed(() => status.trackLoading);

/**
* 获取动态封面
* 仅对网易云来源、非全屏模式、已登录且开关开启的歌曲生效
*/
const fetchDynamicCover = async (): Promise<void> => {
const track = displayTrack.value;
if (!track) {
dynamicCoverUrl.value = "";
return;
}
if (
track.source !== "netease" ||
!isCoverMode.value ||
!user.isLoggedIn ||
!settings.player.dynamicCover ||
trackLoading.value
) {
dynamicCoverUrl.value = "";
return;
}

// 停止已有的再放送定时器,重置加载状态
dynamicCoverStop();
dynamicCoverLoaded.value = false;

const url = await getDynamicCover(track.id);
if (displayTrack.value?.id !== track.id) return; // 歌曲已切换
dynamicCoverUrl.value = url ?? "";
};

/** 封面再放送:视频结束后 2s 重新播放 */
const { start: dynamicCoverStart, stop: dynamicCoverStop } = useTimeoutFn(
() => {
dynamicCoverLoaded.value = true;
nextTick(() => {
videoRef.value?.play();
});
},
2000,
{ immediate: false },
);

const onDynamicCoverEnded = (): void => {
dynamicCoverLoaded.value = false;
dynamicCoverStart();
};

const cleanupDynamicCover = (): void => {
if (videoRef.value) {
videoRef.value.pause();
videoRef.value.src = "";
videoRef.value.load();
}
dynamicCoverUrl.value = "";
dynamicCoverLoaded.value = false;
};

watch(
() =>
[
displayTrack.value?.id,
settings.player.dynamicCover,
settings.player.coverLayout,
status.trackLoading,
] as const,
() => {
fetchDynamicCover();
},
);

onMounted(() => {
fetchDynamicCover();
});

onBeforeUnmount(() => {
dynamicCoverStop();
cleanupDynamicCover();
});
</script>

<template>
Expand All @@ -44,6 +141,20 @@ watchEffect(async () => {
"
>
<SImg :src="coverSrc" class="size-full" />
<!-- 动态封面 -->
<Transition name="fade" mode="out-in">
<video
v-if="dynamicCoverUrl && !fullscreen"
ref="videoRef"
:src="dynamicCoverUrl"
:class="['dynamic-cover', { loaded: dynamicCoverLoaded }]"
muted
autoplay
playsinline
@loadeddata="dynamicCoverLoaded = true"
@ended="onDynamicCoverEnded"
/>
</Transition>
</div>
</template>

Expand All @@ -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;
}
</style>
4 changes: 4 additions & 0 deletions src/i18n/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/i18n/locales/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -868,6 +868,10 @@
"label": "预载下一首歌曲",
"description": "提前获取下一首的播放信息,可能会增加网络占用"
},
"dynamicCover": {
"label": "动态封面",
"description": "可展示部分歌曲的动态封面,仅在封面模式有效"
},
"outputDevice": {
"label": "输出设备",
"description": "选择音频输出设备",
Expand Down
8 changes: 8 additions & 0 deletions src/settings/categories/player.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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,
},
],
},
{
Expand Down
1 change: 1 addition & 0 deletions src/stores/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ export const useSettingsStore = defineStore(
showLyricInBar: true,
preloadNextTrack: false,
searchPlayBehavior: "current",
dynamicCover: false,
});

/** 强迫症设置 */
Expand Down
2 changes: 2 additions & 0 deletions src/types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,8 @@ export interface PlayerSettings {
preloadNextTrack: boolean;
/** 搜索页播放行为 */
searchPlayBehavior: SearchPlayBehavior;
/** 是否展示动态封面(仅网易云来源,封面模式有效) */
dynamicCover: boolean;
}

/** 外观设置 */
Expand Down