feat: 重构页面

This commit is contained in:
lqtmcstudio
2026-02-02 13:54:31 +08:00
parent e8b1fc29e5
commit 9fc08c59d7
14 changed files with 65 additions and 2875 deletions

View File

@@ -1,13 +1,7 @@
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
import { app, ipcMain, BrowserWindow, Menu } from "electron";
import { ipcMain, BrowserWindow, app, Menu } from "electron";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import path from "node:path";
import os from "node:os";
import net from "node:net";
import { spawn } from "node:child_process";
createRequire(import.meta.url);
const __dirname$1 = path.dirname(fileURLToPath(import.meta.url));
process.env.APP_ROOT = path.join(__dirname$1, "..");
@@ -16,131 +10,6 @@ const MAIN_DIST = path.join(process.env.APP_ROOT, "dist-electron");
const RENDERER_DIST = path.join(process.env.APP_ROOT, "dist");
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, "public") : RENDERER_DIST;
let win;
class MpvController {
// 用于处理 JSON 数据粘包
constructor() {
__publicField(this, "process", null);
__publicField(this, "socket", null);
__publicField(this, "socketPath");
__publicField(this, "buffer", "");
const socketName = `mpv-socket-${Date.now()}`;
this.socketPath = process.platform === "win32" ? `\\\\.\\pipe\\${socketName}` : path.join(os.tmpdir(), socketName);
}
start(binaryPath) {
console.log(`[MPV] Starting from: ${binaryPath}`);
console.log(`[MPV] IPC Socket: ${this.socketPath}`);
this.process = spawn(binaryPath, [
"--idle",
// 空闲时不退出
"--no-video",
// 纯音频模式
"--keep-open=yes",
// 播放结束不退出
`--input-ipc-server=${this.socketPath}`
// 指定 IPC 监听地址
]);
this.process.on("error", (err) => {
console.error("[MPV] Process Error:", err);
});
this.process.on("exit", (code) => {
var _a;
console.log(`[MPV] Process exited with code ${code}`);
(_a = this.socket) == null ? void 0 : _a.destroy();
});
this.connectSocket();
}
connectSocket(retries = 10) {
setTimeout(() => {
const socket = net.createConnection(this.socketPath);
socket.on("connect", () => {
console.log("[MPV] IPC Connected!");
this.socket = socket;
this.setupObservers();
});
socket.on("data", (data) => this.handleData(data));
socket.on("error", (err) => {
if (retries > 0) {
this.connectSocket(retries - 1);
} else {
console.error("[MPV] Failed to connect to IPC socket:", err);
}
});
}, 500);
}
// 处理接收到的数据 (解决 TCP 数据包粘连或截断问题)
handleData(data) {
this.buffer += data.toString();
const lines = this.buffer.split("\n");
this.buffer = lines.pop() || "";
lines.forEach((line) => {
if (!line.trim()) return;
try {
const message = JSON.parse(line);
console.log(message);
this.handleMessage(message);
} catch (e) {
console.error("[MPV] JSON Parse Error:", e);
}
});
}
// 处理解析后的 JSON 消息
handleMessage(msg) {
if (!win) return;
if (msg.event === "property-change") {
switch (msg.name) {
case "time-pos":
win.webContents.send("mpv-time-update", msg.data);
break;
case "duration":
win.webContents.send("mpv-duration", msg.data);
break;
case "pause":
win.webContents.send("mpv-play-state", !msg.data);
break;
}
}
if (msg.event === "end-file") {
win.webContents.send("mpv-ended");
win.webContents.send("mpv-play-state", false);
}
}
// 初始化监听属性
setupObservers() {
this.send(["observe_property", 1, "time-pos"]);
this.send(["observe_property", 2, "duration"]);
this.send(["observe_property", 3, "pause"]);
}
// 发送命令给 MPV
send(command) {
if (!this.socket || this.socket.destroyed) return;
const payload = JSON.stringify({ command });
this.socket.write(payload + "\n");
}
// === 业务方法 ===
load(url, autoPlay) {
if (autoPlay) {
this.send(["set_property", "pause", false]);
this.send(["loadfile", url, "replace"]);
} else {
this.send(["set_property", "pause", true]);
this.send(["loadfile", url, "replace"]);
}
}
play() {
this.send(["set_property", "pause", false]);
}
pause() {
this.send(["set_property", "pause", true]);
}
seek(time) {
this.send(["seek", time, "absolute"]);
}
setVolume(volume) {
this.send(["set_property", "volume", volume]);
}
}
const mpv = new MpvController();
const mpvExecutablePath = app.isPackaged ? path.join(process.resourcesPath, "core", "mpv.exe") : path.join(process.env.APP_ROOT, "core", "mpv.exe");
function createWindow() {
win = new BrowserWindow({
frame: false,
@@ -163,6 +32,7 @@ function createWindow() {
} else {
win.loadFile(path.join(RENDERER_DIST, "index.html"));
}
registerZoomShortcuts(win);
}
ipcMain.on("window-minimize", (event) => {
var _a;
@@ -171,17 +41,32 @@ ipcMain.on("window-minimize", (event) => {
ipcMain.on("window-maximize", () => (win == null ? void 0 : win.isMaximized()) ? win.unmaximize() : win == null ? void 0 : win.maximize());
ipcMain.on("window-close", () => win == null ? void 0 : win.close());
ipcMain.handle("window-is-maximized", () => (win == null ? void 0 : win.isMaximized()) || false);
ipcMain.on("mpv-load", (_, url, autoPlay = true) => mpv.load(url, autoPlay));
ipcMain.on("mpv-play", () => mpv.play());
ipcMain.on("mpv-pause", () => mpv.pause());
ipcMain.on("mpv-seek", (_, time) => mpv.seek(time));
ipcMain.on("mpv-volume", (_, volume) => mpv.setVolume(volume));
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
win = null;
}
});
function registerZoomShortcuts(win2) {
win2.webContents.on("before-input-event", (event, input) => {
if (input.control || input.meta) {
if (input.key.toLowerCase() === "=" || input.key === "+") {
let currentZoom = win2.webContents.getZoomFactor();
win2.webContents.setZoomFactor(currentZoom + 0.1);
event.preventDefault();
} else if (input.key === "-" || input.key === "_") {
let currentZoom = win2.webContents.getZoomFactor();
if (currentZoom > 0.5) {
win2.webContents.setZoomFactor(currentZoom - 0.1);
}
event.preventDefault();
} else if (input.key === "0") {
win2.webContents.setZoomFactor(1);
event.preventDefault();
}
}
});
}
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
@@ -189,7 +74,6 @@ app.on("activate", () => {
});
app.whenReady().then(() => {
Menu.setApplicationMenu(null);
mpv.start(mpvExecutablePath);
createWindow();
});
export {

View File

@@ -5,16 +5,5 @@ electron.contextBridge.exposeInMainWorld("electronAPI", {
minimizeWindow: () => electron.ipcRenderer.send("window-minimize"),
maximizeWindow: () => electron.ipcRenderer.send("window-maximize"),
closeWindow: () => electron.ipcRenderer.send("window-close"),
isMaximized: () => electron.ipcRenderer.invoke("window-is-maximized"),
// MPV 控制 (Renderer -> Main)
mpvLoad: (url, autoPlay = true) => electron.ipcRenderer.send("mpv-load", url, autoPlay),
mpvPlay: () => electron.ipcRenderer.send("mpv-play"),
mpvPause: () => electron.ipcRenderer.send("mpv-pause"),
mpvSeek: (time) => electron.ipcRenderer.send("mpv-seek", time),
mpvSetVolume: (volume) => electron.ipcRenderer.send("mpv-volume", volume),
// MPV 事件 (Main -> Renderer)
onMpvTimeUpdate: (callback) => electron.ipcRenderer.on("mpv-time-update", (_, time) => callback(time)),
onMpvDuration: (callback) => electron.ipcRenderer.on("mpv-duration", (_, duration) => callback(duration)),
onMpvPlayState: (callback) => electron.ipcRenderer.on("mpv-play-state", (_, isPlaying) => callback(isPlaying)),
onMpvEnded: (callback) => electron.ipcRenderer.on("mpv-ended", () => callback())
isMaximized: () => electron.ipcRenderer.invoke("window-is-maximized")
});

View File

@@ -2,10 +2,8 @@ import { app, BrowserWindow, Menu, ipcMain } from 'electron'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import os from 'node:os'
import net from 'node:net'
import { spawn, type ChildProcess } from 'node:child_process'
// @ts-ignore
const require = createRequire(import.meta.url)
const __dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -19,162 +17,8 @@ process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT,
let win: BrowserWindow | null
class MpvController {
private process: ChildProcess | null = null;
private socket: net.Socket | null = null;
private socketPath: string;
private buffer: string = '';
constructor() {
const socketName = `mpv-socket-${Date.now()}`;
this.socketPath = process.platform === 'win32'
? `\\\\.\\pipe\\${socketName}`
: path.join(os.tmpdir(), socketName);
}
start(binaryPath: string) {
console.log(`[MPV] Starting from: ${binaryPath}`);
console.log(`[MPV] IPC Socket: ${this.socketPath}`);
// 启动 MPV 进程
this.process = spawn(binaryPath, [
'--idle', // 空闲时不退出
'--no-video', // 纯音频模式
'--keep-open=yes', // 播放结束不退出
`--input-ipc-server=${this.socketPath}`
]);
this.process.on('error', (err) => {
console.error('[MPV] Process Error:', err);
});
this.process.on('exit', (code) => {
console.log(`[MPV] Process exited with code ${code}`);
this.socket?.destroy();
});
this.connectSocket();
}
private connectSocket(retries = 10) {
setTimeout(() => {
const socket = net.createConnection(this.socketPath);
socket.on('connect', () => {
console.log('[MPV] IPC Connected!');
this.socket = socket;
this.setupObservers();
});
socket.on('data', (data) => this.handleData(data));
socket.on('error', (err) => {
if (retries > 0) {
// MPV 还没准备好,继续重试
this.connectSocket(retries - 1);
} else {
console.error('[MPV] Failed to connect to IPC socket:', err);
}
});
}, 500); // 500ms 重试间隔
}
// 处理接收到的数据
private handleData(data: Buffer) {
this.buffer += data.toString();
// MPV 的消息以 \n 分隔
const lines = this.buffer.split('\n');
// 最后一个元素可能是不完整的行,留到下一次处理
this.buffer = lines.pop() || '';
lines.forEach(line => {
if (!line.trim()) return;
try {
const message = JSON.parse(line);
console.log(message);
this.handleMessage(message);
} catch (e) {
console.error('[MPV] JSON Parse Error:', e);
}
});
}
// 处理解析后的 JSON 消息
private handleMessage(msg: any) {
if (!win) return;
// 处理属性变更事件
if (msg.event === 'property-change') {
switch (msg.name) {
case 'time-pos':
win.webContents.send('mpv-time-update', msg.data);
break;
case 'duration':
win.webContents.send('mpv-duration', msg.data);
break;
case 'pause':
win.webContents.send('mpv-play-state', !msg.data); // data=true 意味着暂停
break;
}
}
// 处理播放结束事件
if (msg.event === 'end-file') {
win.webContents.send('mpv-ended');
win.webContents.send('mpv-play-state', false);
}
}
// 初始化监听属性
private setupObservers() {
this.send(['observe_property', 1, 'time-pos']);
this.send(['observe_property', 2, 'duration']);
this.send(['observe_property', 3, 'pause']);
}
// 发送命令给 MPV
send(command: any[]) {
if (!this.socket || this.socket.destroyed) return;
const payload = JSON.stringify({ command });
this.socket.write(payload + '\n');
}
// === 业务方法 ===
load(url: string, autoPlay: boolean) {
if (autoPlay) {
// 确保取消暂停 (防止之前是暂停状态)
this.send(['set_property', 'pause', false]);
// 加载文件
this.send(['loadfile', url, 'replace']);
} else {
// 先设置为暂停 (这样后续加载的文件会继承这个暂停状态)
this.send(['set_property', 'pause', true]);
// 加载文件
this.send(['loadfile', url, 'replace']);
}
}
play() { this.send(['set_property', 'pause', false]); }
pause() { this.send(['set_property', 'pause', true]); }
seek(time: number) { this.send(['seek', time, 'absolute']); }
setVolume(volume: number) { this.send(['set_property', 'volume', volume]); }
}
const mpv = new MpvController();
// === Electron 窗口逻辑 ===
const mpvExecutablePath = app.isPackaged
? path.join(process.resourcesPath, 'core', 'mpv.exe')
: path.join(process.env.APP_ROOT, 'core', 'mpv.exe');
function createWindow() {
win = new BrowserWindow({
frame: false,
@@ -199,6 +43,8 @@ function createWindow() {
} else {
win.loadFile(path.join(RENDERER_DIST, 'index.html'))
}
registerZoomShortcuts(win)
}
// === IPC 监听 ===
@@ -208,14 +54,6 @@ ipcMain.on('window-maximize', () => win?.isMaximized() ? win.unmaximize() : win?
ipcMain.on('window-close', () => win?.close())
ipcMain.handle('window-is-maximized', () => win?.isMaximized() || false)
// MPV 控制指令
ipcMain.on('mpv-load', (_, url, autoPlay = true) => mpv.load(url, autoPlay))
ipcMain.on('mpv-play', () => mpv.play())
ipcMain.on('mpv-pause', () => mpv.pause())
ipcMain.on('mpv-seek', (_, time) => mpv.seek(time))
ipcMain.on('mpv-volume', (_, volume) => mpv.setVolume(volume))
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
@@ -223,6 +61,28 @@ app.on('window-all-closed', () => {
}
})
function registerZoomShortcuts(win: BrowserWindow) {
win.webContents.on('before-input-event', (event, input) => {
if (input.control || input.meta) {
if (input.key.toLowerCase() === '=' || input.key === '+') {
let currentZoom = win.webContents.getZoomFactor();
win.webContents.setZoomFactor(currentZoom + 0.1);
event.preventDefault();
} else if (input.key === '-' || input.key === '_') {
let currentZoom = win.webContents.getZoomFactor();
// Limit minimum zoom to avoid making it too small to see
if (currentZoom > 0.5) {
win.webContents.setZoomFactor(currentZoom - 0.1);
}
event.preventDefault();
} else if (input.key === '0') {
win.webContents.setZoomFactor(1);
event.preventDefault();
}
}
});
}
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
@@ -231,7 +91,5 @@ app.on('activate', () => {
app.whenReady().then(() => {
Menu.setApplicationMenu(null)
// 启动 MPV
mpv.start(mpvExecutablePath);
createWindow()
})
})

View File

@@ -6,24 +6,4 @@ contextBridge.exposeInMainWorld('electronAPI', {
maximizeWindow: () => ipcRenderer.send('window-maximize'),
closeWindow: () => ipcRenderer.send('window-close'),
isMaximized: () => ipcRenderer.invoke('window-is-maximized'),
// MPV 控制 (Renderer -> Main)
mpvLoad: (url: string,autoPlay: boolean = true) => ipcRenderer.send('mpv-load', url, autoPlay),
mpvPlay: () => ipcRenderer.send('mpv-play'),
mpvPause: () => ipcRenderer.send('mpv-pause'),
mpvSeek: (time: number) => ipcRenderer.send('mpv-seek', time),
mpvSetVolume: (volume: number) => ipcRenderer.send('mpv-volume', volume),
// MPV 事件 (Main -> Renderer)
onMpvTimeUpdate: (callback: (time: number) => void) =>
ipcRenderer.on('mpv-time-update', (_, time) => callback(time)),
onMpvDuration: (callback: (duration: number) => void) =>
ipcRenderer.on('mpv-duration', (_, duration) => callback(duration)),
onMpvPlayState: (callback: (isPlaying: boolean) => void) =>
ipcRenderer.on('mpv-play-state', (_, isPlaying) => callback(isPlaying)),
onMpvEnded: (callback: () => void) =>
ipcRenderer.on('mpv-ended', () => callback()),
})

View File

@@ -1,290 +1,11 @@
<template>
<div class="app-container" :class="{ 'dark-mode': store.darkMode }">
<SideBar />
<main class="main-content">
<header class="header-bar">
<div class="header-left">
<!-- 路由导航按钮 -->
<div class="navigation-buttons">
<button
class="nav-btn"
@click="handleBack"
:disabled="!canGoBack"
title="返回"
>
<Icon icon="lucide:chevron-left" width="18" />
</button>
<button
class="nav-btn"
@click="handleForward"
:disabled="!canGoForward"
title="前进"
>
<Icon icon="lucide:chevron-right" width="18" />
</button>
</div>
</div>
<WindowControls />
</header>
<div class="content-scrollable">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</div>
</main>
<footer class="footer-player">
<PlayerBar />
</footer>
</div>
<MainLayout />
</template>
<script setup lang="ts">
import { usePlayerStore } from './stores/playerStore';
import SideBar from './components/layout/SideBar.vue';
import WindowControls from './components/layout/WindowControls.vue';
import PlayerBar from './components/layout/PlayerBar.vue';
import { Icon } from '@iconify/vue';
import { ref, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router';
const store = usePlayerStore();
const router = useRouter();
const route = useRoute();
// Register IPC
store.init()
// 跟踪路由历史,判断是否可以返回和前进
const canGoBack = ref(true); // 返回按钮始终可用,让浏览器处理
const canGoForward = ref(false);
// 记录之前的路由路径
const previousPath = ref(route.path);
// 监听路由变化,更新前进按钮状态
watch(
() => route.path,
(newPath) => {
// 当从歌单页返回主页时,前进按钮应该可用
if (newPath === '/' && previousPath.value.startsWith('/playlist/')) {
canGoForward.value = true;
} else if (newPath.startsWith('/playlist/')) {
// 当进入歌单页时,前进按钮不可用
canGoForward.value = false;
}
// 更新之前的路径
previousPath.value = newPath;
},
{ immediate: true }
);
// 监听返回和前进事件
const handleBack = () => {
router.back();
};
const handleForward = () => {
if (canGoForward.value) {
router.forward();
// 前进后前进按钮可能不可用,具体取决于历史记录
canGoForward.value = false;
}
};
import MainLayout from './layout/MainLayout.vue';
</script>
<style lang="scss">
/* 主题变量系统 */
:root {
/* 亮色主题变量 */
--bg-primary: #ffffff;
--bg-secondary: #f6f8fa;
--bg-tertiary: #f9fafb;
--text-primary: #333333;
--text-secondary: #666666;
--text-tertiary: #888888;
--border-color: #e0e0e0;
--border-light: #f0f0f0;
--sidebar-bg: #f6f8fa;
--sidebar-border: #e0e0e0;
--header-bg: #ffffff;
--content-bg: #ffffff;
--player-bg: #ffffff;
--card-bg: #ffffff;
--overlay-bg: rgba(0, 0, 0, 0.4);
--settings-modal-bg: white;
--settings-sidebar-bg: #f9fafb;
--settings-sidebar-border: #eee;
--nav-item-hover: #eee;
--nav-item-active: #333;
--nav-item-active-text: white;
--close-btn-color: #999;
--close-btn-hover: #333;
--dummy-option-border: #f0f0f0;
--logo-placeholder-bg: linear-gradient(45deg, #ff6b6b, #4ecdc4);
}
/* 深色主题变量 */
.dark-mode {
--bg-primary: #1a1a1a;
--bg-secondary: #2d2d2d;
--bg-tertiary: #333333;
--text-primary: #ffffff;
--text-secondary: #b0b0b0;
--text-tertiary: #808080;
--border-color: #404040;
--border-light: #3a3a3a;
--sidebar-bg: #2d2d2d;
--sidebar-border: #404040;
--header-bg: #1a1a1a;
--content-bg: #1a1a1a;
--player-bg: #1a1a1a;
--card-bg: #2d2d2d;
--overlay-bg: rgba(0, 0, 0, 0.7);
--settings-modal-bg: #2d2d2d;
--settings-sidebar-bg: #333333;
--settings-sidebar-border: #404040;
--nav-item-hover: #404040;
--nav-item-active: #6366f1;
--nav-item-active-text: white;
--close-btn-color: #808080;
--close-btn-hover: #ffffff;
--dummy-option-border: #3a3a3a;
--logo-placeholder-bg: linear-gradient(45deg, #6366f1, #8b5cf6);
}
/* 全局重置 */
html, body, #app {
height: 100%;
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
overflow: hidden; /* 防止浏览器默认滚动 */
background-color: var(--bg-primary);
color: var(--text-primary);
transition: background-color 0.6s ease, color 0.6s ease;
}
/* 布局结构 */
.app-container {
display: grid;
grid-template-columns: 220px 1fr; /* 侧边栏宽度 | 主内容 */
grid-template-rows: 1fr auto; /* 主体高度 | 播放器高度 */
transition: background-color 0.6s ease;
height: 100vh;
width: 100vw;
overflow: hidden;
}
/* 侧边栏样式 */
.sidebar {
grid-column: 1 / 2;
grid-row: 1 / 3; /* 侧边栏跨越所有行 */
}
/* 主内容区 */
.main-content {
grid-column: 2 / 3;
grid-row: 1 / 2;
display: flex;
flex-direction: column;
position: relative;
background: var(--content-bg);
overflow: hidden;
/* 顶部拖拽区 */
.header-bar {
height: 60px;
display: flex;
justify-content: space-between;
align-items: center;
-webkit-app-region: drag; /* Electron 拖拽 */
background: var(--header-bg);
position: relative;
z-index: 1000; /* 确保窗口控制按钮在最上层 */
padding-top: 10px; /* 添加上边距,避免贴边 */
.header-left {
padding: 0 20px;
-webkit-app-region: no-drag;
.navigation-buttons {
display: flex;
gap: 12px;
.nav-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
&:hover:not(:disabled) {
background: var(--bg-secondary);
border-color: var(--border-light);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
}
}
}
/* 路由视图滚动区 */
.content-scrollable {
flex: 1;
overflow-y: auto;
padding: 0;
background: var(--content-bg);
position: relative; /* 确保绝对定位的子元素能正确定位 */
/* 隐藏滚动条但保留功能 */
&::-webkit-scrollbar { width: 6px; }
&::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 3px;
}
&::-webkit-scrollbar-track {
background: var(--bg-secondary);
}
}
}
}
/* 底部播放器跨越两列 */
.footer-player {
grid-column: 1 / 3;
grid-row: 2 / 3;
z-index: 200;
background: var(--player-bg);
height: 80px;
position: relative;
}
/* 路由动画 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
<style>
@import "./styles/main.css";
</style>

View File

@@ -1,488 +0,0 @@
<template>
<div class="player-bar" :style="dynamicBackground">
<div class="noise-overlay"></div>
<div class="glass-surface"></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"
crossorigin="anonymous"
@load="handleImageLoad"
alt="Cover"
/>
</div>
<div class="meta">
<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>
<div class="center-container">
<div class="controls-wrapper">
<button class="ctrl-btn sm">
<Icon icon="lucide:skip-back" width="24" />
</button>
<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="24" />
</button>
</div>
</div>
<div class="side-container right">
<div class="extra-controls">
<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>
<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>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { usePlayerStore } from '../../stores/playerStore';
import { Icon } from '@iconify/vue';
const store = usePlayerStore();
const isHoveringProgress = ref(false);
// 动态背景样式
const dynamicBackground = computed(() => {
const { primary, secondary } = store.themeColors;
// 使用 radial-gradient 模拟 Apple Music 的光斑效果
return {
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">
// 引入字体 (推荐在全局 CSS 引入 SF Pro 或 Inter)
$font-stack: "SF Pro Display", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
.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;
// 关键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;
}
// 噪点层
.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: 10;
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
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;
&.right { justify-content: flex-end; }
}
.track-info {
display: flex;
align-items: center;
gap: 16px;
.album-cover {
width: 52px;
height: 52px;
border-radius: 6px; // Apple Music 圆角较小
overflow: hidden;
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 {
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%);
.controls-wrapper {
display: flex;
align-items: center;
gap: 32px; // 更宽的间距
}
}
.ctrl-btn {
background: none;
border: none;
cursor: pointer;
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: white; transform: scale(1.05); }
&:active { transform: scale(0.95); }
&.play-btn {
position: relative;
width: 48px;
height: 48px;
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: 24px;
.time-display {
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: 10px;
width: 120px;
color: rgba(255, 255, 255, 0.7);
}
.icon-btn {
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
transition: color 0.2s;
&.active, &:hover { color: white; }
}
}
}
/* --- 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);
}
}
/* --- 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>

View File

@@ -1,361 +0,0 @@
<template>
<!-- 遮罩层独立动画 -->
<transition name="fade-quick">
<div v-if="store.showSettings" class="backdrop" @click="store.toggleSettings"></div>
</transition>
<!-- 弹窗动画 -->
<transition name="fade-slide">
<div v-if="store.showSettings" class="settings-overlay">
<div class="settings-modal">
<aside class="settings-sidebar">
<div class="title">设置</div>
<ul class="nav-list">
<li v-for="item in menuItems" :key="item.id"
:class="{ active: activeTab === item.id }"
@click="activeTab = item.id">
{{ item.name }}
</li>
</ul>
</aside>
<main class="settings-content">
<button class="close-btn" @click="store.toggleSettings">
<Icon icon="lucide:x" width="24" />
</button>
<transition name="fade" mode="out-in">
<!-- 通用设置 -->
<div v-if="activeTab === 'general'" class="section-content" key="general">
<h2>{{ menuItems.find(i => i.id === activeTab)?.name }}</h2>
<div class="dummy-option">
<span>深色模式</span>
<t-switch v-model="store.darkMode" @change="logSetting('darkMode', store.darkMode)" />
</div>
<div class="dummy-option">
<span>自动播放</span>
<t-switch v-model="settings.autoPlay" @change="logSetting('autoPlay', settings.autoPlay)" />
</div>
</div>
<!-- 播放与音质 -->
<div v-else-if="activeTab === 'audio'" class="section-content" key="audio">
<h2>{{ menuItems.find(i => i.id === activeTab)?.name }}</h2>
<div class="dummy-option">
<span>启用高音质 (Hi-Res)</span>
<t-switch v-model="settings.hiResAudio" @change="logSetting('hiResAudio', settings.hiResAudio)" />
</div>
<div class="dummy-option">
<span>开启桌面歌词</span>
<t-switch v-model="settings.desktopLyrics" @change="logSetting('desktopLyrics', settings.desktopLyrics)" />
</div>
<div class="dummy-option">
<span>音量增强</span>
<t-switch v-model="settings.volumeBoost" @change="logSetting('volumeBoost', settings.volumeBoost)" />
</div>
</div>
<!-- 快捷键 -->
<div v-else-if="activeTab === 'shortcut'" class="section-content" key="shortcut">
<h2>{{ menuItems.find(i => i.id === activeTab)?.name }}</h2>
<div class="dummy-option">
<span>启用全局快捷键</span>
<t-switch v-model="settings.globalShortcuts" @change="logSetting('globalShortcuts', settings.globalShortcuts)" />
</div>
<div class="dummy-option">
<span>媒体键控制</span>
<t-switch v-model="settings.mediaKeys" @change="logSetting('mediaKeys', settings.mediaKeys)" />
</div>
</div>
<!-- 下载管理 -->
<div v-else-if="activeTab === 'download'" class="section-content" key="download">
<h2>{{ menuItems.find(i => i.id === activeTab)?.name }}</h2>
<div class="dummy-option">
<span>下载完成后通知</span>
<t-switch v-model="settings.downloadNotify" @change="logSetting('downloadNotify', settings.downloadNotify)" />
</div>
<div class="dummy-option">
<span>高质量下载</span>
<t-switch v-model="settings.hqDownload" @change="logSetting('hqDownload', settings.hqDownload)" />
</div>
</div>
<!-- 关于页面 -->
<div v-else class="about-section" key="about">
<div class="logo-animate">M</div>
<h3>Muse Player</h3>
<p class="version">Version 1.0.0 Beta</p>
<div class="desc">
Designed for music lovers.<br>
Crafted with Vue 3 & Electron.
</div>
<div class="links">
<a href="#">GitHub</a>
<a href="#">Website</a>
</div>
</div>
</transition>
</main>
</div>
</div>
</transition>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue';
import { usePlayerStore } from '../../stores/playerStore';
import { Icon } from '@iconify/vue';
const store = usePlayerStore();
const activeTab = ref('general');
// 其他设置的本地状态管理
const settings = reactive({
autoPlay: true,
hiResAudio: true,
desktopLyrics: false,
volumeBoost: false,
globalShortcuts: true,
mediaKeys: true,
downloadNotify: true,
hqDownload: true
});
// 切换时打印日志
const logSetting = (key: string, value: boolean) => {
console.log(`[设置变更] ${key}: ${value ? '开启' : '关闭'}`);
// 这里可以添加实际保存设置的逻辑
};
const menuItems = [
{ id: 'general', name: '通用' },
{ id: 'audio', name: '播放与音质' },
{ id: 'shortcut', name: '快捷键' },
{ id: 'download', name: '下载管理' },
{ id: 'about', name: '关于' },
];
</script>
<style scoped lang="scss">
/* 遮罩层 - 快速淡入淡出 */
.fade-quick-enter-active,
.fade-quick-leave-active {
transition: opacity 0.1s ease;
}
.fade-quick-enter-from,
.fade-quick-leave-to {
opacity: 0;
}
/* 遮罩层样式 */
.backdrop {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 998;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
}
/* 弹窗容器 */
.settings-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 999;
display: flex;
justify-content: center;
align-items: center;
pointer-events: none; /* 防止点击穿透 */
.settings-modal {
width: 70vw;
height: 70vh;
max-width: 900px;
background: var(--settings-modal-bg);
border-radius: 16px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
display: flex;
overflow: hidden;
position: relative;
pointer-events: auto; /* 恢复弹窗内的点击事件 */
}
.settings-sidebar {
width: 200px;
background: var(--settings-sidebar-bg);
padding: 30px 20px;
border-right: 1px solid var(--settings-sidebar-border);
.title {
font-size: 24px;
font-weight: bold;
margin-bottom: 30px;
padding-left: 10px;
color: var(--text-primary);
}
.nav-list {
list-style: none;
padding: 0;
li {
padding: 12px 16px;
margin-bottom: 8px;
border-radius: 8px;
cursor: pointer;
color: var(--text-secondary);
font-weight: 500;
transition: all 0.2s;
&:hover {
background: var(--nav-item-hover);
color: var(--text-primary);
}
&.active {
background: var(--nav-item-active);
color: var(--nav-item-active-text);
}
}
}
}
.settings-content {
flex: 1;
padding: 40px;
position: relative;
overflow-y: auto;
background: var(--settings-modal-bg);
.close-btn {
position: absolute;
top: 20px;
right: 20px;
background: none;
border: none;
cursor: pointer;
color: var(--close-btn-color);
&:hover {
color: var(--close-btn-hover);
}
}
h2 {
margin-top: 0;
margin-bottom: 30px;
font-size: 20px;
color: var(--text-primary);
}
.dummy-option {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
border-bottom: 1px solid var(--dummy-option-border);
&:last-child {
border-bottom: none;
}
span {
color: var(--text-primary);
}
}
}
/* 关于页面样式 */
.about-section {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
text-align: center;
.logo-animate {
width: 80px;
height: 80px;
background: var(--logo-placeholder-bg);
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 40px;
font-weight: bold;
margin-bottom: 20px;
animation: float 6s ease-in-out infinite;
}
h3 {
margin: 0;
font-size: 24px;
color: var(--text-primary);
}
.version {
color: var(--text-tertiary);
margin: 10px 0 30px;
font-family: monospace;
}
.desc {
line-height: 1.6;
color: var(--text-secondary);
margin-bottom: 40px;
}
.links {
display: flex;
gap: 20px;
a {
color: var(--text-primary);
text-decoration: none;
font-weight: 600;
border-bottom: 1px solid transparent;
transition: border 0.2s;
&:hover {
border-color: var(--text-primary);
}
}
}
}
}
/* 弹窗进入动画 - 遮罩已经独立 */
.fade-slide-enter-active,
.fade-slide-leave-active {
transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.fade-slide-enter-from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
.fade-slide-leave-to {
opacity: 0;
transform: scale(0.95) translateY(-10px);
}
@keyframes float {
0%, 100% {
transform: translateY(0) rotate(0);
}
50% {
transform: translateY(-10px) rotate(5deg);
}
}
</style>

View File

@@ -1,190 +0,0 @@
<template>
<aside class="sidebar">
<div class="brand">
<div class="logo-placeholder">M</div>
<span class="app-name">Muse Player</span>
</div>
<nav class="menu">
<router-link
v-for="item in menuItems"
:key="item.path"
:to="item.path"
class="menu-item"
active-class="active"
:title="item.label"
>
<Icon :icon="item.icon" width="20" />
<span>{{ item.label }}</span>
</router-link>
</nav>
</aside>
</template>
<script setup lang="ts">
import { Icon } from '@iconify/vue';
const menuItems = [
{ path: '/', label: '主页', icon: 'lucide:home' },
{ path: '/local', label: '本地音乐', icon: 'lucide:hard-drive' },
{ path: '/playlist', label: '我的歌单', icon: 'lucide:list-music' }
];
</script>
<style scoped lang="scss">
.sidebar {
width: 220px;
height: 100%;
background-color: var(--sidebar-bg);
display: flex;
flex-direction: column;
padding: 24px 16px;
border-right: 1px solid var(--sidebar-border);
-webkit-app-region: drag;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 0 0 rgba(0, 0, 0, 0.05);
&:hover {
box-shadow: 2px 0 12px rgba(0, 0, 0, 0.08);
}
.brand {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 48px;
padding-left: 12px;
-webkit-app-region: no-drag;
transition: all 0.3s ease;
opacity: 0;
animation: fadeInDown 0.6s ease forwards;
.logo-placeholder {
width: 36px;
height: 36px;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
border-radius: 12px;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 18px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
&:hover {
transform: scale(1.05);
box-shadow: 0 6px 16px rgba(99, 102, 241, 0.4);
}
}
.app-name {
font-weight: 600;
font-size: 20px;
color: var(--text-primary);
letter-spacing: -0.02em;
}
}
.menu {
display: flex;
flex-direction: column;
gap: 8px;
-webkit-app-region: no-drag;
.menu-item {
display: flex;
align-items: center;
gap: 14px;
padding: 12px 16px;
border-radius: 12px;
color: var(--text-secondary);
text-decoration: none;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
font-size: 15px;
font-weight: 400;
position: relative;
overflow: hidden;
opacity: 0;
animation: fadeInLeft 0.5s ease forwards;
&:nth-child(1) { animation-delay: 0.2s; }
&:nth-child(2) { animation-delay: 0.3s; }
&:nth-child(3) { animation-delay: 0.4s; }
&::before {
content: '';
position: absolute;
left: 0;
top: 0;
width: 4px;
height: 100%;
background: linear-gradient(180deg, #6366f1, #8b5cf6);
transform: scaleY(0);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 0 2px 2px 0;
}
&:hover {
background-color: var(--nav-item-hover);
color: var(--text-primary);
transform: translateX(4px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
&::before {
transform: scaleY(1);
}
}
&.active {
background-color: linear-gradient(135deg, rgba(99, 102, 241, 0.1), rgba(139, 92, 246, 0.1));
background: var(--bg-primary);
color: #6366f1;
font-weight: 500;
box-shadow: 0 6px 16px rgba(99, 102, 241, 0.15);
transform: translateX(4px);
&::before {
transform: scaleY(1);
}
.iconify {
filter: drop-shadow(0 0 4px rgba(99, 102, 241, 0.5));
}
}
span {
transition: all 0.3s ease;
}
&:hover span {
transform: translateX(2px);
}
}
}
}
// 动画定义
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInLeft {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
</style>

View File

@@ -1,57 +0,0 @@
<template>
<div class="window-controls">
<div class="traffic-lights">
<div class="light minimize" @click="handleMinimize"></div>
<div class="light maximize" @click="handleMaximize"></div>
<div class="light close" @click="handleClose"></div>
</div>
</div>
</template>
<script setup lang="ts">
// ✅ 调用主进程暴露的 API
const handleClose = () => {
window.electronAPI.closeWindow()
}
const handleMinimize = () => {
window.electronAPI.minimizeWindow()
}
const handleMaximize = async () => {
// 可选:切换图标(比如最大化/还原)
await window.electronAPI.maximizeWindow()
}
</script>
<style scoped lang="scss">
.window-controls {
display: flex;
align-items: center;
padding: 0 24px;
height: 100%;
-webkit-app-region: no-drag;
.traffic-lights {
display: flex;
gap: 10px; // 按钮间距拉大
.light {
width: 15px; // 放大按钮 (原12px)
height: 15px;
border-radius: 50%;
cursor: pointer;
position: relative;
transition: transform 0.1s, opacity 0.2s;
&:hover { opacity: 0.8; }
&:active { transform: scale(0.9); }
/* 调整了顺序: 最小化-最大化-关闭,符合一般习惯,也可按需调整 */
&.close { background-color: #ff5f56; }
&.minimize { background-color: #ffbd2e; }
&.maximize { background-color: #27c93f; }
}
}
}
</style>

View File

@@ -4,9 +4,6 @@ import { createPinia } from 'pinia'
import { createRouter, createWebHashHistory } from 'vue-router'
import App from './App.vue'
import HomeView from './views/HomeView.vue'
import PlaylistDetailView from './views/PlaylistDetailView.vue'
import TDesign from 'tdesign-vue-next'
import 'tdesign-vue-next/es/style/index.css'
@@ -14,8 +11,16 @@ const pinia = createPinia()
const router = createRouter({
history: createWebHashHistory(),
routes: [
{ path: '/', component: HomeView },
{ path: '/playlist/:id', component: PlaylistDetailView },
{
path: '/',
name: 'Home',
component: () => import('./views/Home.vue')
},
{
path: '/local',
name: 'Local',
component: () => import('./views/LocalMusic.vue')
}
]
})
@@ -23,4 +28,4 @@ const app = createApp(App)
app.use(pinia)
app.use(router)
app.use(TDesign)
app.mount('#app')
app.mount('#app')

View File

@@ -1,188 +0,0 @@
import { defineStore } from 'pinia';
import { ref, computed, watch } from 'vue';
export const usePlayerStore = defineStore('player', () => {
const currentSong = ref({
id: 1,
title: "Example Track",
artist: "AI Artist",
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
});
const isPlaying = ref(false);
const currentTime = ref(0);
const volume = ref(80);
const showPlaylist = ref(false);
const showSettings = ref(false);
const darkMode = ref(false);
const themeColors = ref({ primary: '#6366f1', secondary: '#a855f7' });
const progressColor = ref('#ffffff');
// 标记是否已初始化监听器,防止重复绑定
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.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 }[] = [];
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 => {
const key = `${Math.floor(color.r / 16)}${Math.floor(color.g / 16)}${Math.floor(color.b / 16)}`;
colorFrequency[key] = (colorFrequency[key] || 0) + 1;
});
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);
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),
b: Math.floor(primary.b * 0.7)
};
progressColor.value = `rgb(${darkPrimary.r}, ${darkPrimary.g}, ${darkPrimary.b})`;
} else {
themeColors.value = { primary: '#6366f1', secondary: '#a855f7' };
progressColor.value = '#ffffff';
}
};
}
const progressPercentage = computed(() =>
currentSong.value.duration ? (currentTime.value / currentSong.value.duration) * 100 : 0
);
return {
currentSong, isPlaying, currentTime, volume, showPlaylist,
themeColors, progressPercentage, progressColor,
togglePlay, seek, extractColors,
showSettings,
darkMode,
init
};
});

View File

@@ -3,18 +3,6 @@ export interface IElectronAPI {
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 {

View File

@@ -1,383 +0,0 @@
<template>
<div class="home-view">
<section class="section-container">
<div class="section-header">
<h2 class="section-title">推荐歌单</h2>
<div class="more">查看全部 <Icon icon="lucide:chevron-right" /></div>
</div>
<div class="playlist-grid">
<div
v-for="item in playlistData"
:key="item.id"
class="playlist-card"
@click="handlePlayListClick(item)"
>
<div class="cover-wrapper">
<img :src="item.img" :alt="item.name" loading="lazy" />
<div class="play-count">
<Icon icon="lucide:play" width="12" />
<span>{{ item.play_count }}</span>
</div>
</div>
<div class="playlist-info">
<div class="playlist-name">{{ item.name }}</div>
<div class="playlist-author">{{ item.author }}</div>
</div>
</div>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { Icon } from '@iconify/vue';
// 注入你提供的 JSON 数据
const playlistData = ref([
{
"play_count": "4.7万",
"id": "17387580241",
"author": "张大佛爷张大佛爷",
"name": "巴西Funk来首精神氮泵助力燃脂",
"time": "2025-11-02",
"img": "http://p2.music.126.net/YkpQqGGlHR4DD8BjGTQe8Q==/109951172228935288.jpg",
"total": 30,
"desc": "巴西放克Funk brasileiro),通常在巴西被称为\"Funk\"是一种受嘻哈音乐Hip Hop影响的音乐风 格,起源于里约热内卢的贫民区(也称为贫民窟)。里约放克(Funk carioca最初源自电音放克Electro和迈阿密贝 斯Miami Bass),之后逐渐发展出独特的风格,并成为巴西低收入青少年中最受欢迎的音乐类型之一。\n\n听说巴西Funk和健身很搭噢",
"source": "wy"
},
{
"play_count": "3.6万",
"id": "14435495551",
"author": "凌晨一点的莱茵猫",
"name": "ACG治愈 | 回忆复刻 重温动漫里的温情时刻",
"time": "2025-10-18",
"img": "http://p2.music.126.net/uPWibcRbc7u3vDD44gXA0A==/109951172205320822.jpg",
"total": 99,
"desc": "不是所有治愈都需要大段对白\n一段旋律就够了\n\n可以是主角低谷时的背景音\n也可以是圆满结局的收尾曲\n\n每一段都裹着当时的情绪\n现在听依旧能暖到心里最软的地方",
"source": "wy"
},
{
"play_count": "3万",
"id": "17438630520",
"author": "露露在发呆",
"name": "跑步 •鬼灭之刃超燃和风主题歌",
"time": "2025-11-15",
"img": "http://p2.music.126.net/DuKyLyYhKd2YhWsRYkd9DQ==/109951172287476927.jpg",
"total": 26,
"desc": "鬼灭之刃即将迎来无限城终极大决战精选历代TV番、剧场版主题曲与超人气配乐插曲与动画同样出彩的是其独特美妙的日式和风电子流行曲风给你今日份的运动计划注入来自鬼灭之刃的二次元能量吧适配有氧、无氧运动 全适配高能KPOP祝你今天也能量满满燃爆卡路里",
"source": "wy"
},
{
"play_count": "1.7万",
"id": "17430413151",
"author": "心语馆",
"name": "【高质量轻音】享受宁静 沉浸自然",
"time": "2025-11-13",
"img": "http://p2.music.126.net/El8lldCevbObAYAcBz0kvg==/109951172275960287.jpg",
"total": 72,
"desc": "当城市喧嚣渐远\n让音符化作自然的信使\n\n钢琴的澄澈如林间晨露\n弦乐的温柔似晚风拂叶\n\n虫鸣与流水声悄然交织\n每一段旋律都在勾勒远山\n\n晴空与旷野的轮廓\n无需刻意追寻\n\n只需闭上眼\n便能在旋律里触摸自然 的呼吸\n\n让身心沉潜于这份不被打扰的宁静\n重拾内心的松弛与澄澈……",
"source": "wy"
},
{
"play_count": "4681",
"id": "17440686473",
"author": "與鯨島",
"name": "刘宇|我生如刀锋 自当斩荆棘",
"time": "2025-11-15",
"img": "http://p2.music.126.net/l6q13AT-n47NICqDGf90lw==/109951172291475298.jpg",
"total": 50,
"desc": "他说:\n“我接受来自四面八方的欢呼鼓舞也接受不明缘由的流言与我接受来自四面八方的欢呼鼓舞也 接受不明缘由的流言与误解。我低谷过,但没人能熄灭我跳动的火焰。眼前不变的是光明和希望,再尖锐的噪音也无法打 断已经响起的旋律,仍在舞蹈,就是我的态度。”\n\n“我很早之前就说过恶评它其实不会伤害到我哪怕我说的一句话 它没有错误有些人都可以在TA的立场上找出这句话的错误我觉得这个不太需要去考虑的因为你做什么事情问心无 愧最重要。”\n\n“我觉得你们也在发光是因为你们的光汇聚在一起我才能这么明亮。”\n\n“天赋固然重要但我想要 才更重要。”\n\n“愿你我皆被温柔以待身处泥泞也嗅得大地芳香。”\n\n“我想告诉你们的是是你们的光唤醒了我心中 的那颗种子,我知道有一天,这颗种子,可以成为为你们遮挡伤害的那棵大树,请相信我。”\n\n“谢谢您曾对我说要去 做那个扇风的人,要让全世界知道这把扇子扇出来的风,叫中国风。”\n\n“国风推广这条路其实我的初心一直不变依 旧在前行,也理解我作为公众人物应该负担起更多的责误解。我低谷过,但没人能熄灭我跳动的火焰。眼前不变的是光明 和希望,再尖锐的噪音也无法打断已经响起的旋律,仍在舞蹈,就是我的态度。”\n\n“我很早之前就说过恶评它其实不 会伤害到我哪怕我说的一句话它没有错误有些人都可以在TA的立场上找出这句话的错误我觉得这个不太需要去考 虑的,因为你做什么事情问心无愧最重要。”\n\n“我觉得你们也在发光是因为你们的光汇聚在一起我才能这么明亮。”\n\n“天赋固然重要但我想要才更重要。”\n\n“愿你我皆被温柔以待身处泥泞也嗅得大地芳香。”\n\n“我想告诉你们的是是你们的光唤醒了我心中的那颗种子我知道有一天这颗种子可以成为为你们遮挡伤害的那棵大树请相信我。”\n\n“谢谢您曾对我说要去做那个扇风的人要让全世界知道这把扇子扇出来的风叫中国风。”\n\n“国风推广这条路其实我的初心一直不变依旧在前行也理解我作为公众人物应该负担起更多的责任、传播的重任。希望未来不负期望 能号召更多的年轻的朋友关注和热爱国风、传统文化。”\n\n“把冠军定在道路上而不是目标上。”\n\n“你们带着特别热 情,特别想要跟你传达他们的爱的时候,你就觉得,这点累算什么。”\n\n“太在意恶评是对好评的不负责。”\n\n",
"source": "wy"
},
{
"play_count": "2.1万",
"id": "17412466554",
"author": "走马川行北海边",
"name": "网上很火韩语歌曲|宿命感拉满氛围神曲",
"time": "2025-11-08",
"img": "http://p2.music.126.net/Rf9Nx1eefoGbqGhqq84SBw==/109951172254139596.jpg",
"total": 72,
"desc": "前奏一响,便是跨越时空的宿命羁绊。这些火爆全网的韩语神曲,藏着韩剧里初雪拔剑的悸动、跨越百年的深情,也藏着爱而不得的怅惘与命中注定的奔赴。空灵声线交织着缱绻旋律,氛围感直接拉满,每一段旋律都像在诉说: 有些人,遇见即是宿命,哪怕兜兜转转,也终会被命运牵引。戴上耳机,沉浸式坠入这场关于宿命的浪漫与遗憾。",
"source": "wy"
},
{
"play_count": "1.2万",
"id": "17426483259",
"author": "心语馆",
"name": "【欧美旋律控】前奏封神 耳熟能详欧美神曲",
"time": "2025-11-12",
"img": "http://p2.music.126.net/4EiMzo9LAGbNjwF79ecuZg==/109951172271263928.jpg",
"total": 67,
"desc": "这份歌单,是资深乐迷私藏的欧美旋律宝库。从格莱美获奖金曲,到小众音乐人的惊艳之作,每一首都经过时间与口碑的双重检验。\n\n有Adele用灵魂嗓音唱出的《Rolling in the Deep》那强烈鼓点和凄美钢琴交织让失恋 的痛楚与坚强直击人心也有Coldplay用《Viva la Vida》带来的摇滚盛宴壮丽弦乐搭配激昂节奏展现帝国兴衰。这 里既有流行、摇滚、民谣等主流风格也不乏R&B、电子乐等小众类型满足不同音乐偏好。\n\n无论你是在午后闲暇、深夜独处还是在通勤路上这些旋律都能为你带来极致的听觉享受让你沉浸在欧美音乐的独特魅力中一听就上瘾循 环到停不下来 。",
"source": "wy"
},
{
"play_count": "4198",
"id": "17514849470",
"author": "心语馆",
"name": "【高质量轻音】清新宁静 明亮自然",
"time": "2025-12-04",
"img": "http://p2.music.126.net/v0jiaItY2fD3LuQVrdt_Cg==/109951172384635472.jpg",
"total": 71,
"desc": "蝉鸣躲进树荫,落叶在石阶上打了个转。木吉他的弦音裹着松针的气息,手鼓轻得像松鼠踩过腐叶。不必追赶时间,只需闭眼,让音符替你接住林间漏下的每一缕暖阳。",
"source": "wy"
},
{
"play_count": "1万",
"id": "14407617761",
"author": "露露在发呆",
"name": "华语R&B像一只蝴蝶飞过废墟",
"time": "2025-10-11",
"img": "http://p2.music.126.net/nYl-Rts9LlhdrGRIx5St-g==/109951172136819703.jpg",
"total": 58,
"desc": "我瞒着所有人装作迈过了很多坎,故意显得很开心的样子,事实上只有我知道,阴影就是阴影,有些坎我永远也迈不过去,有些事一时半会释怀,我承认你很好,有些事跟我喜欢你没关系,有些事没在一个频道上,更没办法将两 件事放一起衡量。",
"source": "wy"
},
{
"play_count": "8188",
"id": "17511869819",
"author": "有只黑猫叫灰心",
"name": "「韩语宿命感」首尔晚风里的心跳",
"time": "2025-12-04",
"img": "http://p2.music.126.net/wXRLNUreaGRVcU7CYe20KA==/109951172381558389.jpg",
"total": 50,
"desc": "ᥫᩣ当首尔的晚风裹着便利店的暖光掠过耳机线韩语歌词里那些轻颤的音节正藏着和你同频的心跳共振。这是专属于宿命感的BGM——每一段旋律都像被按下慢放的初遇镜头是走廊转角撞进眼底的视线是咖啡杯沿碰在一起时的轻响是晚风里没说出口的「恰好我也喜欢你」。耳机里的每一句都是藏在旋律里的、只属于你的心动序章✨",
"source": "wy"
}
]);
import { useRouter } from 'vue-router';
const router = useRouter();
const handlePlayListClick = (item: any) => {
router.push(`/playlist/${item.id}`);
};
</script>
<style scoped lang="scss">
.home-view {
padding: 20px 0 40px 0;
.section-container {
margin-bottom: 40px;
padding: 0 20px;
.section-header {
display: flex;
justify-content: space-between;
align-items: flex-end;
margin-bottom: 20px;
.section-title {
font-size: 24px;
font-weight: 800;
color: var(--text-primary);
margin: 0;
}
.more {
font-size: 13px;
color: var(--text-secondary);
display: flex;
align-items: center;
cursor: pointer;
transition: color 0.2s;
&:hover { color: #6366f1; }
}
}
}
.playlist-grid {
display: grid;
// 使用 auto-fill 实现响应式列数
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 24px;
.playlist-card {
cursor: pointer;
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
&:hover {
.cover-wrapper {
transform: translateY(-8px);
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.15);
img { transform: scale(1.05); }
}
.playlist-name { color: #ff6b6b; }
}
.cover-wrapper {
position: relative;
aspect-ratio: 1;
border-radius: 14px;
overflow: hidden;
background: #f0f0f0;
margin-bottom: 12px;
transition: all 0.4s ease;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.6s ease;
}
.play-count {
position: absolute;
top: 8px;
right: 8px;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(8px);
color: white;
padding: 4px 10px;
border-radius: 20px;
font-size: 11px;
display: flex;
align-items: center;
gap: 4px;
z-index: 2;
}
}
.playlist-info {
.playlist-name {
font-size: 14px;
font-weight: 600;
line-height: 1.4;
color: #333;
margin-bottom: 4px;
// 两行文本截断
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
transition: color 0.2s;
}
.playlist-author {
font-size: 12px;
color: #999;
}
}
}
}
}
</style>

View File

@@ -1,568 +0,0 @@
<template>
<div class="playlist-detail">
<!-- 歌单头部信息 -->
<header class="playlist-header">
<div class="header-content">
<div class="cover-area">
<img :src="playlist.info.img" :alt="playlist.info.name" class="playlist-cover" />
<div class="cover-overlay"></div>
</div>
<div class="info-area">
<div class="playlist-type">歌单</div>
<h1 class="playlist-name">{{ playlist.info.name }}</h1>
<div class="playlist-meta">
<span class="author">{{ playlist.info.author }}</span>
<span class="play-count">
<Icon icon="lucide:play" width="14" /> {{ playlist.info.play_count }}
</span>
<span class="song-count">{{ playlist.total }} </span>
</div>
<div class="playlist-desc" v-if="playlist.info.desc">
{{ playlist.info.desc }}
</div>
<div class="action-buttons">
<button class="play-btn">
<Icon icon="lucide:play" width="20" /> 播放全部
</button>
<button class="add-btn">
<Icon icon="lucide:plus" width="18" /> 收藏
</button>
<button class="more-btn">
<Icon icon="lucide:more-vertical" width="18" />
</button>
</div>
</div>
</div>
</header>
<!-- 歌曲列表 -->
<main class="song-list">
<div class="list-header">
<div class="list-title">歌曲列表</div>
<div class="list-info"> {{ playlist.total }} 首歌曲</div>
</div>
<table class="song-table">
<thead>
<tr>
<th class="col-index">#</th>
<th class="col-info">歌曲</th>
<th class="col-album">专辑</th>
<th class="col-duration">时长</th>
<th class="col-action">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(song, index) in playlist.list" :key="song.songmid" class="song-item">
<td class="col-index">{{ index + 1 }}</td>
<td class="col-info">
<div class="song-info">
<img :src="song.s_img" :alt="song.name" class="song-cover" />
<div class="song-detail">
<div class="song-name">{{ song.name }}</div>
<div class="song-singer">{{ song.singer }}</div>
</div>
</div>
</td>
<td class="col-album">{{ song.albumName }}</td>
<td class="col-duration">{{ song.interval }}</td>
<td class="col-action">
<button class="play-action">
<Icon icon="lucide:play" width="16" />
</button>
<button class="more-action">
<Icon icon="lucide:more-vertical" width="16" />
</button>
</td>
</tr>
</tbody>
</table>
</main>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { Icon } from '@iconify/vue';
// 固定歌单数据
const playlist = ref({
"list": [
{
"singer": "那束花本是给她",
"name": "David Lessenger-地球脉动的小曲We Don't Talk Anymore (纯享版) (那束花本是给她 remix",
"albumName": "万物",
"albumId": 286652435,
"source": "wy",
"interval": "03:17",
"songmid": 2750774430,
"img": "https://p2.music.126.net/zT9grAc0aicJbxglDtnPXA==/109951172079101681.jpg?param=1024y1024",
"m_img": "https://p2.music.126.net/zT9grAc0aicJbxglDtnPXA==/109951172079101681.jpg?param=512y512",
"s_img": "https://p2.music.126.net/zT9grAc0aicJbxglDtnPXA==/109951172079101681.jpg?param=128y128",
"types": [
{
"type": "standard",
"size": "4.53MB"
},
{
"type": "exhigh",
"size": "7.55MB"
},
{
"type": "lossless",
"size": "19.43MB"
},
{
"type": "sky",
"size": "19.46MB"
},
{
"type": "jyeffect",
"size": "69.05MB"
},
{
"type": "jymaster",
"size": "118.62MB"
}
]
},
{
"singer": "Taylor Swift、Brendon Urie",
"name": "ME!",
"albumName": "Lover",
"albumId": 80752440,
"source": "wy",
"interval": "03:13",
"songmid": 1382781549,
"img": "https://p2.music.126.net/6CB6Jsmb7k7qiJqfMY5Row==/109951164260234943.jpg?param=1024y1024",
"m_img": "https://p2.music.126.net/6CB6Jsmb7k7qiJqfMY5Row==/109951164260234943.jpg?param=512y512",
"s_img": "https://p2.music.126.net/6CB6Jsmb7k7qiJqfMY5Row==/109951164260234943.jpg?param=128y128",
"types": [
{
"type": "standard",
"size": "4.42MB"
},
{
"type": "exhigh",
"size": "7.37MB"
},
{
"type": "lossless",
"size": "25.86MB"
},
{
"type": "sky",
"size": "23.40MB"
},
{
"type": "jyeffect",
"size": "73.40MB"
},
{
"type": "jymaster",
"size": "127.41MB"
}
]
},
{
"singer": "DECO*27、初音ミク",
"name": "ラビットホール",
"albumName": "ラビットホール",
"albumId": 164629017,
"source": "wy",
"interval": "02:39",
"songmid": 2043178301,
"img": "https://p2.music.126.net/20QetRSvLjUmyyGF__1ALA==/109951168575689926.jpg?param=1024y1024",
"m_img": "https://p2.music.126.net/20QetRSvLjUmyyGF__1ALA==/109951168575689926.jpg?param=512y512",
"s_img": "https://p2.music.126.net/20QetRSvLjUmyyGF__1ALA==/109951168575689926.jpg?param=128y128",
"types": [
{
"type": "standard",
"size": "3.64MB"
},
{
"type": "exhigh",
"size": "6.07MB"
},
{
"type": "lossless",
"size": "20.65MB"
},
{
"type": "sky",
"size": "20.15MB"
},
{
"type": "jyeffect",
"size": "64.58MB"
},
{
"type": "jymaster",
"size": "113.57MB"
}
]
},
{
"singer": "d0tc0mmie、GUMI",
"name": "I Can't Wait (feat. GUMI)",
"albumName": "I Can't Wait (feat. GUMI)",
"albumId": 353993404,
"source": "wy",
"interval": "01:35",
"songmid": 3326907142,
"img": "https://p2.music.126.net/0GEf5ziQ8mG0HfRY8djyvg==/109951172408103691.jpg?param=1024y1024",
"m_img": "https://p2.music.126.net/0GEf5ziQ8mG0HfRY8djyvg==/109951172408103691.jpg?param=512y512",
"s_img": "https://p2.music.126.net/0GEf5ziQ8mG0HfRY8djyvg==/109951172408103691.jpg?param=128y128",
"types": [
{
"type": "standard",
"size": "2.19MB"
},
{
"type": "exhigh",
"size": "3.65MB"
},
{
"type": "lossless",
"size": "12.01MB"
},
{
"type": "hires",
"size": "20.59MB"
},
{
"type": "sky",
"size": "11.29MB"
},
{
"type": "jyeffect",
"size": "36.05MB"
},
{
"type": "jymaster",
"size": "65.54MB"
}
]
},
{
"singer": "梶浦由記",
"name": "Sis puella magica!",
"albumName": "魔法少女まどか☆マギカ Music Collection",
"albumId": 2732401,
"source": "wy",
"interval": "02:49",
"songmid": 28138654,
"img": "https://p2.music.126.net/7cYVfSgSZFZeVheUFDGOIg==/109951166197931207.jpg?param=1024y1024",
"m_img": "https://p2.music.126.net/7cYVfSgSZFZeVheUFDGOIg==/109951166197931207.jpg?param=512y512",
"s_img": "https://p2.music.126.net/7cYVfSgSZFZeVheUFDGOIg==/109951166197931207.jpg?param=128y128",
"types": [
{
"type": "standard",
"size": "3.88MB"
},
{
"type": "exhigh",
"size": "6.47MB"
},
{
"type": "lossless",
"size": "31.72MB"
},
{
"type": "sky",
"size": "17.53MB"
},
{
"type": "jyeffect",
"size": "59.17MB"
},
{
"type": "jymaster",
"size": "100.19MB"
}
]
}
],
"page": 1,
"limit": 5,
"total": 53,
"source": "wy",
"info": {
"play_count": "51",
"name": "我喜欢的音乐",
"img": "https://p1.music.126.net/G3NA3TqQD3L51OkzrhNQmw==/109951172080623907.jpg",
"desc": "",
"author": "蓝蜻蜓017"
}
});
</script>
<style scoped lang="scss">
.playlist-detail {
min-height: 100%;
.playlist-header {
background: var(--header-bg);
padding: 40px 20px;
color: var(--text-primary);
.header-content {
max-width: 1400px;
margin: 0 auto;
display: flex;
gap: 30px;
align-items: flex-end;
.cover-area {
position: relative;
.playlist-cover {
width: 200px;
height: 200px;
object-fit: cover;
border-radius: 12px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.3);
}
.cover-overlay {
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(99, 102, 241, 0.2), rgba(139, 92, 246, 0.2));
border-radius: 12px;
}
}
.info-area {
flex: 1;
padding-bottom: 20px;
.playlist-type {
font-size: 14px;
opacity: 0.9;
margin-bottom: 8px;
}
.playlist-name {
font-size: 36px;
font-weight: 800;
margin: 0 0 16px 0;
line-height: 1.2;
}
.playlist-meta {
display: flex;
gap: 20px;
font-size: 14px;
opacity: 0.9;
margin-bottom: 20px;
.author {
font-weight: 500;
}
.play-count {
display: flex;
align-items: center;
gap: 6px;
}
}
.playlist-desc {
font-size: 14px;
line-height: 1.6;
opacity: 0.9;
margin-bottom: 24px;
max-width: 600px;
}
.action-buttons {
display: flex;
gap: 12px;
button {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
border: none;
border-radius: 24px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
&.play-btn {
background: #6366f1;
color: white;
padding: 12px 24px;
&:hover {
transform: scale(1.05);
box-shadow: 0 6px 16px rgba(99, 102, 241, 0.3);
}
}
&.add-btn {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
&:hover {
background: var(--bg-tertiary);
border-color: var(--text-secondary);
}
}
&.more-btn {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
width: 40px;
height: 40px;
padding: 0;
justify-content: center;
border-radius: 50%;
&:hover {
background: var(--bg-tertiary);
border-color: var(--text-secondary);
}
}
}
}
}
}
}
.song-list {
max-width: 1400px;
margin: 0 auto;
padding: 30px 20px;
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
.list-title {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
}
.list-info {
font-size: 14px;
color: var(--text-secondary);
}
}
.song-table {
width: 100%;
border-collapse: collapse;
background: var(--bg-primary);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
th, td {
padding: 16px 20px;
text-align: left;
border-bottom: 1px solid var(--border-light);
}
th {
background: var(--bg-secondary);
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.col-index {
width: 60px;
font-size: 14px;
color: var(--text-secondary);
}
.col-info {
flex: 1;
}
.col-album {
width: 250px;
font-size: 14px;
color: var(--text-secondary);
}
.col-duration {
width: 100px;
font-size: 14px;
color: var(--text-secondary);
}
.col-action {
width: 80px;
}
.song-item {
transition: background-color 0.2s ease;
&:hover {
background: var(--bg-secondary);
}
.song-info {
display: flex;
align-items: center;
gap: 12px;
.song-cover {
width: 44px;
height: 44px;
object-fit: cover;
border-radius: 6px;
}
.song-detail {
.song-name {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
margin-bottom: 4px;
}
.song-singer {
font-size: 13px;
color: var(--text-secondary);
}
}
}
button {
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 6px;
border-radius: 4px;
transition: all 0.2s ease;
&:hover {
background: var(--nav-item-hover);
color: var(--text-primary);
}
&.play-action:hover {
color: #6366f1;
}
}
}
}
}
}
</style>