forked from miao-moe/QZMusic_PC
feat: 优化界面;IPC-mpv控制;预加载脚本
This commit is contained in:
@@ -56,6 +56,9 @@ const store = usePlayerStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
|
||||
// Register IPC
|
||||
store.init()
|
||||
|
||||
// 跟踪路由历史,判断是否可以返回和前进
|
||||
const canGoBack = ref(true); // 返回按钮始终可用,让浏览器处理
|
||||
const canGoForward = ref(false);
|
||||
|
||||
@@ -1,21 +1,49 @@
|
||||
<template>
|
||||
<div class="player-bar" :style="gradientStyle">
|
||||
<div class="glass-overlay"></div>
|
||||
<div class="player-bar" :style="dynamicBackground">
|
||||
<div class="noise-overlay"></div>
|
||||
<div class="glass-surface"></div>
|
||||
|
||||
<!-- 新增:顶部进度条,使用专辑图提取的最深色 -->
|
||||
<div class="top-progress">
|
||||
<div class="progress-fill" :style="{ width: store.progressPercentage + '%', backgroundColor: store.progressColor }"></div>
|
||||
<div
|
||||
class="progress-container"
|
||||
@mousemove="handleHoverProgress"
|
||||
@mouseleave="isHoveringProgress = false"
|
||||
@click="handleSeek"
|
||||
>
|
||||
<div class="progress-track">
|
||||
<div
|
||||
class="progress-fill"
|
||||
:style="{
|
||||
width: store.progressPercentage + '%',
|
||||
backgroundColor: store.themeColors.secondary
|
||||
}"
|
||||
>
|
||||
<div class="progress-glow"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bar-content">
|
||||
<div class="side-container left">
|
||||
<div class="track-info">
|
||||
<div class="album-cover">
|
||||
<img :src="store.currentSong.cover" @load="store.extractColors" alt="Album Pic"/>
|
||||
<img
|
||||
:src="store.currentSong.cover"
|
||||
crossorigin="anonymous"
|
||||
@load="handleImageLoad"
|
||||
alt="Cover"
|
||||
/>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<div class="song-title">{{ store.currentSong.title }}</div>
|
||||
<div class="artist-name">{{ store.currentSong.artist }}</div>
|
||||
<transition name="fade-slide" mode="out-in">
|
||||
<div :key="store.currentSong.title" class="song-title">
|
||||
{{ store.currentSong.title }}
|
||||
</div>
|
||||
</transition>
|
||||
<transition name="fade-slide" mode="out-in">
|
||||
<div :key="store.currentSong.artist" class="artist-name">
|
||||
{{ store.currentSong.artist }}
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -23,254 +51,438 @@
|
||||
<div class="center-container">
|
||||
<div class="controls-wrapper">
|
||||
<button class="ctrl-btn sm">
|
||||
<Icon icon="lucide:skip-back" width="22" />
|
||||
<Icon icon="lucide:skip-back" width="24" />
|
||||
</button>
|
||||
<button class="ctrl-btn lg play-btn" @click="store.togglePlay">
|
||||
<Icon :icon="store.isPlaying ? 'lucide:pause' : 'lucide:play'" width="28" fill="currentColor" />
|
||||
|
||||
<button
|
||||
class="ctrl-btn play-btn"
|
||||
:class="{ playing: store.isPlaying }"
|
||||
@click="store.togglePlay"
|
||||
>
|
||||
<div class="play-btn-bg"></div>
|
||||
<Icon
|
||||
:icon="store.isPlaying ? 'lucide:pause' : 'lucide:play'"
|
||||
width="32"
|
||||
fill="currentColor"
|
||||
class="play-icon"
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button class="ctrl-btn sm">
|
||||
<Icon icon="lucide:skip-forward" width="22" />
|
||||
<Icon icon="lucide:skip-forward" width="24" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="side-container right">
|
||||
<div class="extra-controls">
|
||||
<!-- 美化后的时间显示,放在右侧最左侧 -->
|
||||
<div class="time-display">
|
||||
{{ formatTime(store.currentTime) }} / {{ formatTime(store.currentSong.duration) }}
|
||||
<span class="time-display">
|
||||
{{ formatTime(store.currentTime) }} / {{ formatTime(store.currentSong.duration) }}
|
||||
</span>
|
||||
|
||||
<div class="volume-box">
|
||||
<Icon v-if="store.volume === 0" icon="lucide:volume-x" width="18" />
|
||||
<Icon v-else-if="store.volume < 50" icon="lucide:volume-1" width="18" />
|
||||
<Icon v-else icon="lucide:volume-2" width="18" />
|
||||
|
||||
<el-slider
|
||||
v-model="store.volume"
|
||||
size="small"
|
||||
class="apple-slider"
|
||||
:show-tooltip="false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Icon icon="lucide:repeat" width="20" class="icon-btn" />
|
||||
<div class="volume-box">
|
||||
<Icon icon="lucide:volume-2" width="20" />
|
||||
<el-slider v-model="store.volume" size="small" class="custom-slider" />
|
||||
</div>
|
||||
<Icon icon="lucide:list-music" width="20"
|
||||
class="icon-btn"
|
||||
:class="{ active: store.showPlaylist }"
|
||||
@click="store.showPlaylist = !store.showPlaylist" />
|
||||
<button
|
||||
class="icon-btn"
|
||||
:class="{ active: store.showPlaylist }"
|
||||
@click="store.showPlaylist = !store.showPlaylist"
|
||||
>
|
||||
<Icon icon="lucide:list-music" width="20" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 保留底部细进度条(鼠标悬停可变粗) -->
|
||||
<div class="bottom-progress">
|
||||
<div class="progress-fill" :style="{ width: store.progressPercentage + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
import { usePlayerStore } from '../../stores/playerStore';
|
||||
import { Icon } from '@iconify/vue';
|
||||
|
||||
const store = usePlayerStore();
|
||||
const isHoveringProgress = ref(false);
|
||||
|
||||
const gradientStyle = computed(() => {
|
||||
// 动态背景样式
|
||||
const dynamicBackground = computed(() => {
|
||||
const { primary, secondary } = store.themeColors;
|
||||
// 使用 radial-gradient 模拟 Apple Music 的光斑效果
|
||||
return {
|
||||
background: `linear-gradient(135deg, ${store.themeColors.primary}, ${store.themeColors.secondary})`
|
||||
background: `
|
||||
radial-gradient(circle at 0% 0%, ${primary} 0%, transparent 60%),
|
||||
radial-gradient(circle at 100% 100%, ${secondary} 0%, transparent 60%),
|
||||
linear-gradient(135deg, rgba(20,20,20,0.8) 0%, rgba(30,30,30,0.9) 100%)
|
||||
`,
|
||||
backgroundColor: '#1a1a1a' // 兜底色
|
||||
};
|
||||
});
|
||||
|
||||
// 处理图片加载并取色
|
||||
function handleImageLoad(e: Event) {
|
||||
const img = e.target as HTMLImageElement;
|
||||
// 调用优化后的取色函数 (建议将此函数移至 Store 或 Utils,这里演示直接调用)
|
||||
extractColorsBetter(img);
|
||||
}
|
||||
|
||||
// 占位函数:进度条交互
|
||||
function handleHoverProgress(e: MouseEvent) { isHoveringProgress.value = true; }
|
||||
function handleSeek(e: MouseEvent) { /* 调用 store.seek */ }
|
||||
|
||||
function formatTime(val: number) {
|
||||
if (!val || isNaN(val)) return '0:00';
|
||||
const m = Math.floor(val / 60);
|
||||
const s = Math.floor(val % 60);
|
||||
return `${m}:${s < 10 ? '0' + s : s}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 优化版取色逻辑 (可放入 Store)
|
||||
* 逻辑:转 HSL,优先取高饱和度(S)和适中亮度(L)的颜色
|
||||
*/
|
||||
function extractColorsBetter(img: HTMLImageElement) {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const scale = 0.2; // 降低分辨率以提高性能
|
||||
canvas.width = Math.floor(img.width * scale);
|
||||
canvas.height = Math.floor(img.height * scale);
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
|
||||
// 简单的 HSL 转换工具
|
||||
const rgbToHsl = (r: number, g: number, b: number) => {
|
||||
r /= 255; g /= 255; b /= 255;
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
let h = 0, s, l = (max + min) / 2;
|
||||
if (max === min) { h = s = 0; } else {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
|
||||
case g: h = (b - r) / d + 2; break;
|
||||
case b: h = (r - g) / d + 4; break;
|
||||
}
|
||||
h /= 6;
|
||||
}
|
||||
return [h, s, l];
|
||||
};
|
||||
|
||||
let bestPrimary = { r: 0, g: 0, b: 0, score: -Infinity };
|
||||
let bestSecondary = { r: 0, g: 0, b: 0, score: -Infinity };
|
||||
|
||||
// 步长设为 16 加速遍历
|
||||
for (let i = 0; i < imageData.length; i += 16) {
|
||||
const r = imageData[i], g = imageData[i + 1], b = imageData[i + 2];
|
||||
const [h, s, l] = rgbToHsl(r, g, b);
|
||||
|
||||
// 过滤太黑、太白、太灰的颜色
|
||||
if (l < 0.1 || l > 0.9 || s < 0.2) continue;
|
||||
|
||||
// 评分算法:饱和度越高分越高,亮度适中分越高
|
||||
// Apple Music 偏好鲜艳的颜色
|
||||
const score = s * 10 - Math.abs(l - 0.5) * 5;
|
||||
|
||||
if (score > bestPrimary.score) {
|
||||
// 当前第一名降级为第二名
|
||||
bestSecondary = { ...bestPrimary };
|
||||
bestPrimary = { r, g, b, score };
|
||||
} else if (score > bestSecondary.score) {
|
||||
bestSecondary = { r, g, b, score };
|
||||
}
|
||||
}
|
||||
|
||||
// 如果找不到颜色(比如全黑白封面),给默认值
|
||||
if (bestPrimary.score === -Infinity) {
|
||||
store.themeColors = { primary: '#555', secondary: '#333' };
|
||||
return;
|
||||
}
|
||||
|
||||
store.themeColors = {
|
||||
primary: `rgb(${bestPrimary.r}, ${bestPrimary.g}, ${bestPrimary.b})`,
|
||||
// 如果第二颜色太弱,稍微调亮主色作为副色
|
||||
secondary: bestSecondary.score > -Infinity
|
||||
? `rgb(${bestSecondary.r}, ${bestSecondary.g}, ${bestSecondary.b})`
|
||||
: `rgba(${bestPrimary.r}, ${bestPrimary.g}, ${bestPrimary.b}, 0.5)`
|
||||
};
|
||||
|
||||
// 进度条颜色取主色的反白或高亮
|
||||
store.progressColor = `rgba(255,255,255,0.9)`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.player-bar {
|
||||
height: 80px; // 稍微增高以容纳布局
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: background 0.5s ease; // 颜色切换动画
|
||||
// 引入字体 (推荐在全局 CSS 引入 SF Pro 或 Inter)
|
||||
$font-stack: "SF Pro Display", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
|
||||
.glass-overlay {
|
||||
.player-bar {
|
||||
position: relative;
|
||||
height: 84px; // 稍微加高,显得大气
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
transition: background 1s ease; // 颜色切换要非常平滑
|
||||
font-family: $font-stack;
|
||||
user-select: none;
|
||||
|
||||
// 玻璃表面层
|
||||
.glass-surface {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(255, 255, 255, 0.15); // 稍微提亮
|
||||
backdrop-filter: blur(20px); // 毛玻璃
|
||||
// 关键:iOS 风格的毛玻璃 + 饱和度提升,让背景色透出来更鲜艳
|
||||
backdrop-filter: blur(50px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(50px) saturate(180%);
|
||||
background: rgba(30, 30, 30, 0.45);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||
z-index: 1;
|
||||
border-top: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
|
||||
// 噪点层
|
||||
.noise-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0.04;
|
||||
z-index: 2;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bar-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
height: 100%;
|
||||
z-index: 10;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 3%; // 使用百分比 padding 适应缩放
|
||||
color: white; // 假设提取的颜色较深,文字用白色,反之需计算反色
|
||||
padding: 0 32px;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
/* 左右容器,确保中间绝对居中 */
|
||||
/* --- 顶部进度条 (Apple 风格:极细 -> 悬浮变粗) --- */
|
||||
.progress-container {
|
||||
position: absolute;
|
||||
top: -1px; // 贴顶
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 4px; // 热区高度
|
||||
z-index: 20;
|
||||
cursor: pointer;
|
||||
transition: height 0.2s ease;
|
||||
|
||||
.progress-track {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
// 默认颜色,会被 inline-style 覆盖
|
||||
background: white;
|
||||
transition: width 0.1s linear;
|
||||
|
||||
// 进度条末端的辉光
|
||||
.progress-glow {
|
||||
position: absolute;
|
||||
right: -4px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: inherit;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 10px rgba(255, 255, 255, 0.8);
|
||||
opacity: 0; // 默认隐藏
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
height: 6px; // 悬浮变粗
|
||||
.progress-fill .progress-glow { opacity: 1; }
|
||||
}
|
||||
}
|
||||
|
||||
/* --- 左侧:信息 --- */
|
||||
.side-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0; // 防止 flex item 溢出
|
||||
|
||||
&.right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
min-width: 0;
|
||||
&.right { justify-content: flex-end; }
|
||||
}
|
||||
|
||||
/* 左侧信息 */
|
||||
.track-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
.album-cover {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 8px;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 6px; // Apple Music 圆角较小
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
|
||||
flex-shrink: 0;
|
||||
img { width: 100%; height: 100%; object-fit: cover; }
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); // 更深邃的阴影
|
||||
background: #333;
|
||||
img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.5s; }
|
||||
|
||||
&:hover img { transform: scale(1.05); } // 细微交互
|
||||
}
|
||||
|
||||
.meta {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
.song-title { font-size: 1.1rem; font-weight: 600; text-overflow: ellipsis; overflow: hidden; }
|
||||
.artist-name { font-size: 0.9rem; opacity: 0.8; margin-top: 4px; }
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
|
||||
.song-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.2px;
|
||||
// 长文字渐变消失遮罩
|
||||
mask-image: linear-gradient(90deg, #000 85%, transparent 100%);
|
||||
}
|
||||
|
||||
.artist-name {
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 绝对居中容器 */
|
||||
/* --- 中间:控制 --- */
|
||||
.center-container {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 200px; // 限制宽度
|
||||
|
||||
.controls-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
gap: 32px; // 更宽的间距
|
||||
}
|
||||
}
|
||||
|
||||
/* 按钮样式 */
|
||||
.ctrl-btn {
|
||||
background: transparent;
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255,255,255,0.9);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: transform 0.2s, color 0.2s;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
transition: all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover { color: #fff; transform: scale(1.1); }
|
||||
&:hover { color: white; transform: scale(1.05); }
|
||||
&:active { transform: scale(0.95); }
|
||||
|
||||
&.play-btn {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
&:hover { background: rgba(255,255,255,0.3); }
|
||||
color: white; // 播放图标始终高亮
|
||||
|
||||
// 播放按钮背景 (毛玻璃圆)
|
||||
.play-btn-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(10px);
|
||||
transition: background 0.2s;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
&:hover .play-btn-bg { background: rgba(255, 255, 255, 0.25); }
|
||||
&.playing .play-icon { transform: scale(0.9); } // 细微视觉调整
|
||||
}
|
||||
}
|
||||
|
||||
/* 右侧图标 */
|
||||
/* --- 右侧:功能 --- */
|
||||
.extra-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
gap: 24px;
|
||||
|
||||
.time-display {
|
||||
margin-right: auto;
|
||||
padding-right: 20px; // 与图标保持距离
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s;
|
||||
&:hover { opacity: 1; }
|
||||
&.active { color: #ffeb3b; opacity: 1; }
|
||||
font-variant-numeric: tabular-nums; // 数字等宽,防止跳动
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.volume-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 底部微型进度条 */
|
||||
.bottom-progress {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
z-index: 3;
|
||||
cursor: pointer;
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: rgba(255,255,255,0.6);
|
||||
transition: width 0.1s linear;
|
||||
gap: 10px;
|
||||
width: 120px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
height: 6px;
|
||||
.progress-fill { background: #fff; }
|
||||
.icon-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
&.active, &:hover { color: white; }
|
||||
}
|
||||
}
|
||||
/* 顶部进度条 */
|
||||
.top-progress {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
z-index: 4;
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
transition: width 0.2s ease;
|
||||
background-color: white; // fallback
|
||||
}
|
||||
}
|
||||
.time-display {
|
||||
font-family: 'SF Mono', 'Roboto Mono', Menlo, monospace;
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
opacity: 0.9;
|
||||
letter-spacing: 0.5px;
|
||||
min-width: 100px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* 覆盖 Element Slider 样式以适配深色背景 */
|
||||
:deep(.custom-slider) {
|
||||
--el-slider-main-bg-color: rgba(255,255,255,0.8);
|
||||
--el-slider-runway-bg-color: rgba(255,255,255,0.2);
|
||||
.el-slider__bar { background-color: white; }
|
||||
.el-slider__button { border-color: white; width: 12px; height: 12px; }
|
||||
/* --- Apple 风格 Slider --- */
|
||||
:deep(.apple-slider) {
|
||||
--el-slider-height: 4px;
|
||||
--el-slider-button-size: 12px;
|
||||
--el-slider-main-bg-color: rgba(255, 255, 255, 0.9);
|
||||
--el-slider-runway-bg-color: rgba(255, 255, 255, 0.15);
|
||||
|
||||
.el-slider__bar {
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.el-slider__button {
|
||||
background: white;
|
||||
border: none;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
|
||||
transition: transform 0.1s;
|
||||
opacity: 0; // 默认隐藏滑块圆点
|
||||
}
|
||||
|
||||
// 悬浮时显示滑块圆点
|
||||
&:hover .el-slider__button {
|
||||
opacity: 1;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
/* --- Vue 动画 --- */
|
||||
.fade-slide-enter-active,
|
||||
.fade-slide-leave-active {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.fade-slide-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(5px);
|
||||
}
|
||||
.fade-slide-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
</style>
|
||||
@@ -16,7 +16,6 @@ const router = createRouter({
|
||||
routes: [
|
||||
{ path: '/', component: HomeView },
|
||||
{ path: '/playlist/:id', component: PlaylistDetailView },
|
||||
// 可扩展其他路由
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
id: 1,
|
||||
title: "Example Track",
|
||||
artist: "AI Artist",
|
||||
cover: "http://p2.music.126.net/W3VMsSEjTdvhz7h3a0oxTg==/17782401556325576.jpg?param=130y130",
|
||||
url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3",
|
||||
cover: "http://p2.music.126.net/h2vun-h_uGBYzGvQoLKiBw==/109951165966921437.jpg?param=130y130",
|
||||
url: "https://m704.music.126.net/20260115210245/494fafc7ecc89da85365b1e6533cdb30/jdyyaac/obj/w5rDlsOJwrLDjj7CmsOj/32280537391/40ea/84dd/db94/451cfc92afa4a12926f40b1183eca3cd.m4a?vuutv=yqFO4JPFSDRDeqVLCjH3fPvuLTHnPPNLPMIBbHWfYTZOmqP5/RFh7UnxA2sG1X9+MLdjYsrkG5vUIUjV6t+y1pniceMN5lePyr33C0D1Aho=&authSecret=0000019bc1a9710615420a3283920006&cdntag=bWFyaz1vc193ZWIscXVhbGl0eV9leGhpZ2g",
|
||||
duration: 0
|
||||
});
|
||||
|
||||
@@ -18,91 +18,148 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
const showSettings = ref(false);
|
||||
const darkMode = ref(false);
|
||||
const themeColors = ref({ primary: '#6366f1', secondary: '#a855f7' });
|
||||
|
||||
// 新增:用于顶部进度条的最深色
|
||||
const progressColor = ref('#ffffff');
|
||||
|
||||
const audio = new Audio(currentSong.value.url);
|
||||
audio.volume = volume.value / 100;
|
||||
// 标记是否已初始化监听器,防止重复绑定
|
||||
let isInitialized = false;
|
||||
|
||||
// 颜色提取函数(轻量版,不依赖外部库)
|
||||
// --- 初始化函数:在组件挂载时调用一次 ---
|
||||
function init() {
|
||||
if (isInitialized) return;
|
||||
|
||||
// 监听时间更新
|
||||
window.electronAPI.onMpvTimeUpdate((time) => {
|
||||
currentTime.value = time;
|
||||
});
|
||||
|
||||
// 监听时长更新 (MPV 加载完元数据后会发送)
|
||||
window.electronAPI.onMpvDuration((duration) => {
|
||||
currentSong.value.duration = duration;
|
||||
});
|
||||
|
||||
// 监听播放状态 (用于同步 MPV 内部状态和 UI)
|
||||
window.electronAPI.onMpvPlayState((playing) => {
|
||||
isPlaying.value = playing;
|
||||
});
|
||||
|
||||
// 监听结束
|
||||
window.electronAPI.onMpvEnded(() => {
|
||||
isPlaying.value = false;
|
||||
currentTime.value = 0;
|
||||
// 这里可以添加自动播放下一首的逻辑
|
||||
});
|
||||
|
||||
// 初始化音量
|
||||
window.electronAPI.mpvSetVolume(volume.value);
|
||||
|
||||
// 加载初始歌曲
|
||||
loadCurrentSong(false);
|
||||
|
||||
isInitialized = true;
|
||||
}
|
||||
|
||||
|
||||
function loadCurrentSong(autoPlay:boolean=true) {
|
||||
if(currentSong.value.url) {
|
||||
window.electronAPI.mpvLoad(currentSong.value.url,autoPlay);
|
||||
}
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
if (isPlaying.value) {
|
||||
window.electronAPI.mpvPause();
|
||||
} else {
|
||||
window.electronAPI.mpvPlay();
|
||||
}
|
||||
isPlaying.value = !isPlaying.value;
|
||||
}
|
||||
|
||||
function seek(time: number) {
|
||||
window.electronAPI.mpvSeek(time);
|
||||
currentTime.value = time; // 立即更新 UI 防止跳变
|
||||
}
|
||||
|
||||
// 监听音量变化
|
||||
watch(volume, (newVol) => {
|
||||
window.electronAPI.mpvSetVolume(newVol);
|
||||
});
|
||||
|
||||
// 监听歌曲 URL 变化 (切歌)
|
||||
watch(() => currentSong.value.url, () => {
|
||||
loadCurrentSong(true);
|
||||
});
|
||||
|
||||
// 监听封面变化提取颜色 (保持原有逻辑不变)
|
||||
watch(() => currentSong.value.cover, () => {
|
||||
extractColors();
|
||||
}, { immediate: true });
|
||||
|
||||
// --- 颜色提取逻辑 (保持不变) ---
|
||||
function extractColors() {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'Anonymous';
|
||||
img.src = currentSong.value.cover + '?t=' + Date.now(); // 避免缓存
|
||||
img.src = currentSong.value.cover + '?t=' + Date.now();
|
||||
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// 缩小图片以提高性能
|
||||
const scale = 0.1;
|
||||
canvas.width = Math.floor(img.width * scale);
|
||||
canvas.height = Math.floor(img.height * scale);
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
// 收集所有像素颜色
|
||||
const colors: { r: number; g: number; b: number; brightness: number }[] = [];
|
||||
|
||||
const colors: { r: number; g: number; b: number; brightness: number }[] = [];
|
||||
|
||||
for (let i = 0; i < imageData.length; i += 4) {
|
||||
const r = imageData[i];
|
||||
const g = imageData[i + 1];
|
||||
const b = imageData[i + 2];
|
||||
const brightness = (r + g + b) / 3;
|
||||
|
||||
colors.push({ r, g, b, brightness });
|
||||
}
|
||||
|
||||
// 计算颜色频率(简单实现)
|
||||
|
||||
const colorFrequency: Record<string, number> = {};
|
||||
colors.forEach(color => {
|
||||
// 将颜色量化为 16 位色,减少颜色数量
|
||||
const key = `${Math.floor(color.r / 16)}${Math.floor(color.g / 16)}${Math.floor(color.b / 16)}`;
|
||||
colorFrequency[key] = (colorFrequency[key] || 0) + 1;
|
||||
});
|
||||
|
||||
// 转换回 RGB 并按频率排序
|
||||
|
||||
const sortedColors = Object.entries(colorFrequency)
|
||||
.map(([key, count]) => {
|
||||
const r = parseInt(key[0], 16) * 16;
|
||||
const g = parseInt(key[1], 16) * 16;
|
||||
const b = parseInt(key[2], 16) * 16;
|
||||
return { r, g, b, count };
|
||||
})
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
// 提取主色调和辅助色调
|
||||
.map(([key, count]) => {
|
||||
const r = parseInt(key[0], 16) * 16;
|
||||
const g = parseInt(key[1], 16) * 16;
|
||||
const b = parseInt(key[2], 16) * 16;
|
||||
return { r, g, b, count };
|
||||
})
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
if (sortedColors.length > 0) {
|
||||
// 主色调:频率最高的颜色
|
||||
const primary = sortedColors[0];
|
||||
const primaryColor = `rgb(${primary.r}, ${primary.g}, ${primary.b})`;
|
||||
|
||||
// 辅助色调:选择与主色调亮度差异较大的颜色
|
||||
|
||||
let secondary = sortedColors[1] || sortedColors[0];
|
||||
let maxBrightnessDiff = Math.abs(primary.brightness - secondary.brightness);
|
||||
|
||||
// 在频率较高的颜色中寻找最合适的辅助色
|
||||
|
||||
for (let i = 1; i < Math.min(10, sortedColors.length); i++) {
|
||||
const currentBrightness = (sortedColors[i].r + sortedColors[i].g + sortedColors[i].b) / 3;
|
||||
const brightnessDiff = Math.abs(primary.brightness - currentBrightness);
|
||||
|
||||
|
||||
if (brightnessDiff > maxBrightnessDiff) {
|
||||
maxBrightnessDiff = brightnessDiff;
|
||||
secondary = sortedColors[i];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const secondaryColor = `rgb(${secondary.r}, ${secondary.g}, ${secondary.b})`;
|
||||
|
||||
// 更新主题颜色
|
||||
|
||||
themeColors.value = {
|
||||
primary: primaryColor,
|
||||
secondary: secondaryColor
|
||||
};
|
||||
|
||||
// 提取进度条颜色(使用主色调的深色版本)
|
||||
|
||||
const darkPrimary = {
|
||||
r: Math.floor(primary.r * 0.7),
|
||||
g: Math.floor(primary.g * 0.7),
|
||||
@@ -110,59 +167,22 @@ export const usePlayerStore = defineStore('player', () => {
|
||||
};
|
||||
progressColor.value = `rgb(${darkPrimary.r}, ${darkPrimary.g}, ${darkPrimary.b})`;
|
||||
} else {
|
||||
// 默认颜色
|
||||
themeColors.value = { primary: '#6366f1', secondary: '#a855f7' };
|
||||
progressColor.value = '#ffffff';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 监听歌曲切换时重新提取颜色
|
||||
watch(() => currentSong.value.cover, () => {
|
||||
extractColors();
|
||||
console.log("!!")
|
||||
}, { immediate: true });
|
||||
|
||||
audio.addEventListener('loadedmetadata', () => {
|
||||
currentSong.value.duration = audio.duration;
|
||||
});
|
||||
|
||||
audio.addEventListener('timeupdate', () => {
|
||||
currentTime.value = audio.currentTime;
|
||||
});
|
||||
|
||||
audio.addEventListener('ended', () => {
|
||||
isPlaying.value = false;
|
||||
currentTime.value = 0;
|
||||
});
|
||||
|
||||
function togglePlay() {
|
||||
if (isPlaying.value) {
|
||||
audio.pause();
|
||||
} else {
|
||||
audio.play().catch(e => console.warn("播放失败,需用户交互:", e));
|
||||
}
|
||||
isPlaying.value = !isPlaying.value;
|
||||
}
|
||||
|
||||
function seek(time: number) {
|
||||
audio.currentTime = time;
|
||||
}
|
||||
function toggleSettings() { console.log("!!");showSettings.value = !showSettings.value; }
|
||||
|
||||
watch(volume, (newVol) => {
|
||||
audio.volume = newVol / 100;
|
||||
});
|
||||
|
||||
const progressPercentage = computed(() =>
|
||||
currentSong.value.duration ? (currentTime.value / currentSong.value.duration) * 100 : 0
|
||||
currentSong.value.duration ? (currentTime.value / currentSong.value.duration) * 100 : 0
|
||||
);
|
||||
|
||||
return {
|
||||
currentSong, isPlaying, currentTime, volume, showPlaylist,
|
||||
themeColors, progressPercentage, progressColor,
|
||||
togglePlay, seek, extractColors,
|
||||
showSettings, toggleSettings,
|
||||
darkMode
|
||||
showSettings,
|
||||
darkMode,
|
||||
init
|
||||
};
|
||||
});
|
||||
});
|
||||
24
src/types/electron.d.ts
vendored
Normal file
24
src/types/electron.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
export interface IElectronAPI {
|
||||
minimizeWindow: () => void;
|
||||
maximizeWindow: () => void;
|
||||
closeWindow: () => void;
|
||||
isMaximized: () => Promise<boolean>;
|
||||
|
||||
mpvLoad: (url: string,autoPlay?: boolean) => void;
|
||||
mpvPlay: () => void;
|
||||
mpvPause: () => void;
|
||||
mpvResume: () => void;
|
||||
mpvSeek: (time: number) => void;
|
||||
mpvSetVolume: (volume: number) => void;
|
||||
|
||||
onMpvTimeUpdate: (callback: (time: number) => void) => void;
|
||||
onMpvDuration: (callback: (duration: number) => void) => void;
|
||||
onMpvPlayState: (callback: (isPlaying: boolean) => void) => void;
|
||||
onMpvEnded: (callback: () => void) => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: IElectronAPI
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user