mirror of
https://github.com/imsyy/SPlayer.git
synced 2025-11-25 11:29:26 +08:00
✨ feat: 支持动态封面
This commit is contained in:
@@ -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 },
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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, // 歌词翻译大小
|
||||
|
||||
@@ -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;
|
||||
|
||||
// 自定义图片工具栏
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
// 结束
|
||||
|
||||
Reference in New Issue
Block a user