feat: 支持动态封面

This commit is contained in:
imsyy
2024-10-11 14:39:31 +08:00
parent 963343c39e
commit 44cfbbf4e0
9 changed files with 171 additions and 71 deletions

View File

@@ -109,3 +109,15 @@ export const matchSong = (
params: { title, artist, album, duration, md5 },
});
};
/**
* 歌曲动态封面
* @param {number} id - 歌曲 id
*/
export const songDynamicCover = (id: number) => {
return request({
url: "/song/dynamic/cover",
params: { id },
});
};

View File

@@ -15,25 +15,16 @@
<div class="artist-item">
<!-- 封面 -->
<div class="cover">
<n-image
<s-image
:src="item.coverSize?.m || item.cover"
default-src="/images/artist.jpg?assest"
class="cover-img"
preview-disabled
lazy
@load="coverLoaded"
>
<template #placeholder>
<div class="cover-loading">
<img src="/images/artist.jpg?assest" class="loading-img" alt="loading-img" />
</div>
</template>
</n-image>
/>
<!-- 封面背板 -->
<n-image
class="cover-shadow"
preview-disabled
lazy
<s-image
:src="item.coverSize?.m || item.cover"
default-src="/images/artist.jpg?assest"
class="cover-shadow"
/>
<!-- 图标 -->
<SvgIcon name="Artist" />
@@ -78,7 +69,6 @@
<script setup lang="ts">
import type { ArtistType } from "@/types/main";
import { coverLoaded } from "@/utils/helper";
interface Props {
data: ArtistType[];
@@ -124,15 +114,6 @@ const router = useRouter();
aspect-ratio: 1 / 1;
border-radius: 50%;
transition: border-radius 0.3s;
:deep(img) {
width: 100%;
height: 100%;
opacity: 0;
transition:
opacity 0.3s,
filter 0.3s,
transform 0.3s;
}
.cover-img {
border-radius: 50%;
overflow: hidden;
@@ -145,6 +126,7 @@ const router = useRouter();
opacity: 0;
position: absolute;
border-radius: 50%;
overflow: hidden;
top: 20%;
width: 80%;
height: auto;

View File

@@ -116,6 +116,7 @@ import { useMusicStore, useStatusStore } from "@/stores";
import { VirtList } from "vue-virt-list";
import { cloneDeep, entries, isEmpty } from "lodash-es";
import { sortOptions } from "@/utils/meta";
import { renderIcon } from "@/utils/helper";
import SongListMenu from "@/components/Menu/SongListMenu.vue";
import player from "@/utils/player";
@@ -225,7 +226,7 @@ const sortMenuOptions = computed<DropdownOption[]>(() =>
key,
label: name,
show: show === "all" ? true : show === props.type ? true : false,
icon,
icon: renderIcon(icon),
})),
);

View File

@@ -8,36 +8,79 @@
alt="pointer"
/>
<!-- 专辑图片 -->
<n-image
<s-image
:key="musicStore.getSongCover()"
:src="musicStore.getSongCover('l')"
class="cover-img"
preview-disabled
@load="coverLoaded"
>
<template #placeholder>
<div class="cover-loading">
<img src="/images/song.jpg?assest" class="loading-img" alt="loading-img" />
</div>
</template>
</n-image>
/>
<!-- 动态封面 -->
<Transition name="fade" mode="out-in">
<video
v-if="dynamicCover && settingStore.dynamicCover && settingStore.playerType === 'cover'"
ref="videoRef"
:src="dynamicCover"
:class="['dynamic-cover', { loaded: dynamicCoverLoaded }]"
muted
autoplay
@loadeddata="dynamicCoverLoaded = true"
@ended="dynamicCoverEnded"
/>
</Transition>
</div>
</template>
<script setup lang="ts">
import { songDynamicCover } from "@/api/song";
import { useSettingStore, useStatusStore, useMusicStore } from "@/stores";
import { isEmpty } from "lodash-es";
const musicStore = useMusicStore();
const statusStore = useStatusStore();
const settingStore = useSettingStore();
// 封面加载完成
const coverLoaded = (e: Event) => {
const target = e.target as HTMLElement | null;
if (target && target.nodeType === Node.ELEMENT_NODE) {
target.style.opacity = "1";
// 动态封面
const dynamicCover = ref<string>("");
const dynamicCoverLoaded = ref<boolean>(false);
// 视频元素
const videoRef = ref<HTMLVideoElement | null>(null);
// 封面再放送
const { start: dynamicCoverStart, stop: dynamicCoverStop } = useTimeoutFn(
() => {
dynamicCoverLoaded.value = true;
videoRef.value?.play();
},
2000,
{ immediate: false },
);
// 获取动态封面
const getDynamicCover = async () => {
if (!musicStore.playSong.id || !settingStore.dynamicCover || settingStore.playerType !== "cover")
return;
dynamicCoverStop();
dynamicCoverLoaded.value = false;
const result = await songDynamicCover(musicStore.playSong.id);
if (!isEmpty(result.data) && result?.data?.videoPlayUrl) {
dynamicCover.value = result.data.videoPlayUrl;
} else {
dynamicCover.value = "";
}
};
// 封面播放结束
const dynamicCoverEnded = () => {
dynamicCoverLoaded.value = false;
dynamicCoverStart();
};
watch(
() => [musicStore.playSong.id, settingStore.dynamicCover, settingStore.playerType],
() => getDynamicCover(),
);
onMounted(getDynamicCover);
</script>
<style lang="scss" scoped>
@@ -56,16 +99,26 @@ const coverLoaded = (e: Event) => {
.cover-img {
width: 100%;
height: 100%;
border-radius: 32px;
overflow: hidden;
object-fit: cover;
z-index: 1;
box-shadow: 0 0 20px 10px rgba(0, 0, 0, 0.1);
transition: opacity 0.1s ease-in-out;
:deep(img) {
width: 100%;
height: 100%;
opacity: 0;
transition: opacity 0.3s ease-in-out;
}
.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;
}
}
&.record {
@@ -177,6 +230,8 @@ const coverLoaded = (e: Event) => {
}
}
&.cover {
border-radius: 32px;
overflow: hidden;
transform: scale(0.9);
&.playing {
transform: scale(1);

View File

@@ -174,6 +174,13 @@
</div>
<n-switch v-model:value="settingStore.showPlaylistCount" class="set" :round="false" />
</n-card>
<n-card class="set-item">
<div class="label">
<n-text class="name">动态封面</n-text>
<n-text class="tip" :depth="3">可展示部分歌曲的动态封面仅在封面模式有效</n-text>
</div>
<n-switch v-model:value="settingStore.dynamicCover" class="set" :round="false" />
</n-card>
<n-card class="set-item">
<div class="label">
<n-text class="name">音乐频谱</n-text>

View File

@@ -1,11 +1,24 @@
<!-- 图片组件 -->
<template>
<div ref="imgRef" class="s-image">
<Transition name="fade" mode="out-in">
<img :key="imgSrc" :src="imgSrc" :alt="alt || 'image'" />
</Transition>
<img v-if="!isLoaded" class="loading" :src="src" @load="imageLoaded" />
</div>
<Transition name="fade" mode="out-in">
<div ref="imgContainer" :key="src" class="s-image">
<!-- 加载图片 -->
<Transition name="fade">
<img v-if="!isLoaded" :src="defaultSrc" class="loading" alt="loading" />
</Transition>
<!-- 真实图片 -->
<img
v-if="src"
ref="imgRef"
:src="imgSrc"
:key="imgSrc"
:alt="alt || 'image'"
:class="['cover', { loaded: isLoaded }]"
@load="imageLoaded"
@error="imageError"
/>
</div>
</Transition>
</template>
<script setup lang="ts">
@@ -23,19 +36,22 @@ const props = withDefaults(
const emit = defineEmits<{
// 加载完成
load: [e: Event];
// 加载失败
error: [e: Event];
// 可视状态变化
"update:show": [show: boolean];
}>();
// 图片数据
const imgRef = ref<HTMLImageElement>();
const imgSrc = ref<string>(props.defaultSrc);
const imgSrc = ref<string>();
const imgContainer = ref<HTMLImageElement>();
// 是否加载完成
const isLoaded = ref<boolean>(false);
// 是否可视
const isCanLook = useElementVisibility(imgRef);
const isCanLook = useElementVisibility(imgContainer);
// 图片加载完成
const imageLoaded = (e: Event) => {
@@ -44,23 +60,51 @@ const imageLoaded = (e: Event) => {
emit("load", e);
};
// 图片加载失败
const imageError = (e: Event) => {
isLoaded.value = false;
imgSrc.value = props.defaultSrc;
// 加载失败
emit("error", e);
};
// 可视状态变化
watchOnce(isCanLook, (show) => {
if (show) imgSrc.value = props.src || props.defaultSrc;
emit("update:show", show);
if (show) imgSrc.value = props.src;
});
</script>
<style lang="scss" scoped>
.s-image {
position: relative;
width: 100%;
height: 100%;
img {
width: 100%;
height: 100%;
overflow: hidden;
transition: all 0.3s;
}
.loading {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
}
.cover {
// position: absolute;
// top: 0;
// left: 0;
width: 100%;
height: 100%;
z-index: 1;
opacity: 0;
&.loaded {
opacity: 1;
}
}
}
</style>

View File

@@ -82,6 +82,7 @@ interface SettingState {
realIP: string;
fullPlayerCache: boolean;
scrobbleSong: boolean;
dynamicCover: boolean;
}
export const useSettingStore = defineStore({
@@ -125,6 +126,7 @@ export const useSettingStore = defineStore({
smtcOutputHighQualityCover: false, // 是否输出高清封面
playSongDemo: false, // 是否播放试听歌曲
scrobbleSong: false, // 是否打卡
dynamicCover: true, // 动态封面
// 歌词
lyricFontSize: 46, // 歌词大小
lyricTranFontSize: 22, // 歌词翻译大小

View File

@@ -1,7 +1,6 @@
import type { SongLevelType } from "@/types/main";
import type { ImageRenderToolbarProps } from "naive-ui";
import { compact, findKey, keys, pick, takeWhile } from "lodash-es";
import { renderIcon } from "./helper";
// 音质数据
export const songLevelData = {
@@ -63,15 +62,15 @@ export function getLevelsUpTo(level: string): Partial<typeof songLevelData> {
// 排序选项
export const sortOptions = {
default: { name: "默认排序", show: "all", icon: renderIcon("Sort") },
titleAZ: { name: "标题升序( A - Z ", show: "all", icon: renderIcon("SortAZ") },
titleZA: { name: "标题降序( Z - A ", show: "all", icon: renderIcon("SortZA") },
arAZ: { name: "歌手升序( A - Z ", show: "song", icon: renderIcon("SortAZ") },
arZA: { name: "歌手降序( Z - A ", show: "song", icon: renderIcon("SortZA") },
timeUp: { name: "时长升序", show: "all", icon: renderIcon("SortClockUp") },
timeDown: { name: "时长降序", show: "all", icon: renderIcon("SortClockDown") },
dateUp: { name: "日期升序", show: "radio", icon: renderIcon("SortDateUp") },
dateDown: { name: "日期降序", show: "radio", icon: renderIcon("SortDateDown") },
default: { name: "默认排序", show: "all", icon: "Sort" },
titleAZ: { name: "标题升序( A - Z ", show: "all", icon: "SortAZ" },
titleZA: { name: "标题降序( Z - A ", show: "all", icon: "SortZA" },
arAZ: { name: "歌手升序( A - Z ", show: "song", icon: "SortAZ" },
arZA: { name: "歌手降序( Z - A ", show: "song", icon: "SortZA" },
timeUp: { name: "时长升序", show: "all", icon: "SortClockUp" },
timeDown: { name: "时长降序", show: "all", icon: "SortClockDown" },
dateUp: { name: "日期升序", show: "radio", icon: "SortDateUp" },
dateDown: { name: "日期降序", show: "radio", icon: "SortDateDown" },
} as const;
// 自定义图片工具栏

View File

@@ -279,21 +279,19 @@ class Player {
});
// 播放
this.player.on("play", () => {
window.document.title = this.getPlayerInfo() || "SPlayer";
// ipc
if (isElectron) {
window.electron.ipcRenderer.send("play-status-change", true);
window.electron.ipcRenderer.send("play-song-change", this.getPlayerInfo());
}
// 更改标题
if (!isElectron) window.document.title = this.getPlayerInfo() || "SPlayer";
console.log("▶️ song play:", playSongData);
});
// 暂停
this.player.on("pause", () => {
window.document.title = "SPlayer";
// ipc
if (isElectron) window.electron.ipcRenderer.send("play-status-change", false);
// 更改标题
if (!isElectron) window.document.title = "SPlayer";
console.log("⏸️ song pause:", playSongData);
});
// 结束