feat: 实现功能&优化

- 底部播放栏
- MediaSession
- 插件系统&存储位置
- URL缓存机制
- 整理项目结构
This commit is contained in:
lqtmcstudio
2026-02-03 12:59:04 +08:00
parent 6965858ae3
commit 935038cd93
21 changed files with 2935 additions and 15 deletions

View File

@@ -1,14 +1,16 @@
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 { ipcMain, BrowserWindow, app, Menu } from "electron";
import { app, ipcMain, BrowserWindow, Menu } from "electron";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import path$1 from "node:path";
import fs$1 from "node:fs";
import { spawn } from "child_process";
import { Socket } from "net";
import { EventEmitter } from "events";
import path from "path";
import fs from "fs";
class MpvController extends EventEmitter {
constructor(ipcPath) {
super();
@@ -142,6 +144,50 @@ class MpvController extends EventEmitter {
}
}
}
const require$1 = createRequire(import.meta.url);
class PluginSystem {
constructor(pluginId) {
__publicField(this, "pluginId");
__publicField(this, "plugin", null);
this.pluginId = pluginId;
this.loadPlugin();
}
loadPlugin() {
try {
const pluginPath = path.join(
app.getPath("userData"),
"plugins",
this.pluginId,
"index.js"
);
if (!fs.existsSync(pluginPath)) {
throw new Error(`Plugin ${this.pluginId} not found`);
}
delete require$1.cache[require$1.resolve(pluginPath)];
this.plugin = require$1(pluginPath);
} catch (e) {
console.error(`[PluginSystem] load failed:`, e);
this.plugin = null;
}
}
async getUrl(id, quality) {
var _a;
if (!((_a = this.plugin) == null ? void 0 : _a.getUrl)) {
return {
success: false,
error: "getUrl not implemented"
};
}
try {
return await this.plugin.getUrl(id, quality);
} catch (e) {
return {
success: false,
error: e.message || "plugin error"
};
}
}
}
createRequire(import.meta.url);
const __dirname$1 = path$1.dirname(fileURLToPath(import.meta.url));
process.env.APP_ROOT = path$1.join(__dirname$1, "..");
@@ -173,6 +219,7 @@ function createWindow() {
} else {
win.loadFile(path$1.join(RENDERER_DIST, "index.html"));
}
win.webContents.openDevTools();
registerZoomShortcuts(win);
}
ipcMain.on("window-minimize", (event) => {
@@ -192,8 +239,21 @@ ipcMain.handle("mpv-play", () => mpv == null ? void 0 : mpv.play());
ipcMain.handle("mpv-pause", () => mpv == null ? void 0 : mpv.pause());
ipcMain.handle("mpv-toggle-pause", () => mpv == null ? void 0 : mpv.togglePause());
ipcMain.handle("mpv-stop", () => mpv == null ? void 0 : mpv.stop());
ipcMain.handle("mpv-set-volume", (e, vol) => mpv == null ? void 0 : mpv.setVolume(vol));
ipcMain.handle("mpv-set-volume", (_, vol) => mpv == null ? void 0 : mpv.setVolume(vol));
ipcMain.handle("mpv-seek", (_, time) => mpv == null ? void 0 : mpv.seek(time));
ipcMain.handle(
"plugin:call",
async (_evenv, pluginId, method, args) => {
const plugin = new PluginSystem(pluginId);
if (typeof plugin[method] !== "function") {
return {
success: false,
error: `Method ${method} not found`
};
}
return await plugin[method](...args);
}
);
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
@@ -231,6 +291,29 @@ app.on("activate", () => {
}
});
app.whenReady().then(() => {
const pluginsPath = path$1.join(app.getPath("userData"), "plugins");
if (!fs$1.existsSync(pluginsPath)) {
fs$1.mkdirSync(pluginsPath, { recursive: true });
}
const wyPluginPath = path$1.join(pluginsPath, "wy");
const wyPluginIndex = path$1.join(wyPluginPath, "index.js");
if (!fs$1.existsSync(wyPluginIndex)) {
if (!fs$1.existsSync(wyPluginPath)) fs$1.mkdirSync(wyPluginPath, { recursive: true });
fs$1.writeFileSync(wyPluginIndex, `
module.exports = {
async getUrl(id, quality) {
const url = \`https://api.qz.shiqianjiang.cn/music/url?source=wy&songId=\${id}&quality=\${quality}&key=testkey\`;
try {
const response = await fetch(url);
const data = await response.json();
return data;
} catch (e) {
return { success: false, error: e.message };
}
}
}
`.trim());
}
Menu.setApplicationMenu(null);
createWindow();
mpv = new MpvController();

View File

@@ -16,5 +16,9 @@ electron.contextBridge.exposeInMainWorld("electronAPI", {
setVolume: (vol) => electron.ipcRenderer.invoke("mpv-set-volume", vol),
seek: (time) => electron.ipcRenderer.invoke("mpv-seek", time),
onEvent: (callback) => electron.ipcRenderer.on("mpv-event", callback)
},
// Plugin System
plugin: {
call: (pluginId, method, args) => electron.ipcRenderer.invoke("plugin:call", pluginId, method, args)
}
});

View File

@@ -2,8 +2,9 @@ import { app, BrowserWindow, Menu, ipcMain } from 'electron'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import fs from 'node:fs'
import { MpvController } from './mpvController'
import { PluginSystem } from '../src/main/pluginSystem.ts'
// @ts-ignore
const require = createRequire(import.meta.url)
const __dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -45,7 +46,7 @@ function createWindow() {
} else {
win.loadFile(path.join(RENDERER_DIST, 'index.html'))
}
win.webContents.openDevTools();
registerZoomShortcuts(win)
}
@@ -69,9 +70,25 @@ ipcMain.handle('mpv-play', () => mpv?.play())
ipcMain.handle('mpv-pause', () => mpv?.pause())
ipcMain.handle('mpv-toggle-pause', () => mpv?.togglePause())
ipcMain.handle('mpv-stop', () => mpv?.stop())
ipcMain.handle('mpv-set-volume', (e, vol) => mpv?.setVolume(vol))
ipcMain.handle('mpv-set-volume', (_, vol) => mpv?.setVolume(vol))
ipcMain.handle('mpv-seek', (_, time) => mpv?.seek(time))
// PluginSystem
ipcMain.handle(
'plugin:call',
async (_evenv, pluginId: string, method: string, args: any[]) => {
const plugin = new PluginSystem(pluginId)
if (typeof (plugin as any)[method] !== 'function') {
return {
success: false,
error: `Method ${method} not found`
}
}
return await (plugin as any)[method](...args)
}
)
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
@@ -115,6 +132,34 @@ app.on('activate', () => {
})
app.whenReady().then(() => {
// Ensure plugins directory exists
const pluginsPath = path.join(app.getPath('userData'), 'plugins')
if (!fs.existsSync(pluginsPath)) {
fs.mkdirSync(pluginsPath, { recursive: true })
}
// --- Ensure Sample 'wy' Plugin Exists ---
const wyPluginPath = path.join(pluginsPath, 'wy')
const wyPluginIndex = path.join(wyPluginPath, 'index.js')
if (!fs.existsSync(wyPluginIndex)) {
if (!fs.existsSync(wyPluginPath)) fs.mkdirSync(wyPluginPath, { recursive: true })
fs.writeFileSync(wyPluginIndex, `
module.exports = {
async getUrl(id, quality) {
const url = \`https://api.qz.shiqianjiang.cn/music/url?source=wy&songId=\${id}&quality=\${quality}&key=testkey\`;
try {
const response = await fetch(url);
const data = await response.json();
return data;
} catch (e) {
return { success: false, error: e.message };
}
}
}
`.trim())
}
// ----------------------------------------
Menu.setApplicationMenu(null)
createWindow()

173
electron/mpvController.ts Normal file
View File

@@ -0,0 +1,173 @@
import { spawn, ChildProcess } from 'child_process';
import { Socket } from 'net';
import { EventEmitter } from 'events';
import path from 'path';
export class MpvController extends EventEmitter {
private process: ChildProcess | null = null;
private socket: Socket | null = null;
private ipcPath: string;
private messageBuffer: string = '';
constructor(ipcPath?: string) {
super();
this.ipcPath = ipcPath || this.getIpcPath();
}
private getIpcPath(): string {
if (process.platform === 'win32') {
return '\\\\.\\pipe\\qzmusic_mpv_socket';
}
return '/tmp/qzmusic_mpv_socket';
}
private getMpvPath(): string {
const appRoot = process.env.APP_ROOT || process.cwd();
if (process.platform === 'win32') {
return path.join(appRoot, 'core', 'mpv.exe');
}
// Darwin (Mac) or Linux
// For now, assuming global mpv or a specific path in future
return 'mpv';
}
start() {
const mpvPath = this.getMpvPath();
console.log('Starting MPV from:', mpvPath);
this.process = spawn(mpvPath, [
'--idle',
'--force-window=no',
'--no-media-controls',
`--input-ipc-server=${this.ipcPath}`,
'--no-terminal'
]);
this.process.on('error', (err) => {
console.error('Failed to start MPV:', err);
this.emit('error', err);
});
this.process.on('exit', (code, signal) => {
console.log(`MPV exited with code ${code} and signal ${signal}`);
this.emit('exit', { code, signal });
this.socket?.destroy();
});
this.tryConnect();
}
private tryConnect(retries = 10) {
if (retries <= 0) {
console.error('Could not connect to MPV socket after multiple attempts.');
return;
}
setTimeout(() => {
this.socket = new Socket();
this.socket.on('connect', () => {
console.log('Connected to MPV IPC socket');
this.emit('ready');
this.send(['observe_property', 1, 'pause']);
this.send(['observe_property', 2, 'time-pos']);
this.send(['observe_property', 3, 'duration']);
this.send(['observe_property', 4, 'idle-active']);
this.send(['observe_property', 5, 'eof-reached']);
});
this.socket.on('data', (data) => {
this.handleData(data);
});
this.socket.on('error', (err) => {
this.socket?.destroy();
this.tryConnect(retries - 1);
});
this.socket.connect(this.ipcPath);
}, 500);
}
private handleData(data: Buffer) {
// Determine message boundaries by newline
const raw = data.toString();
// console.log('[MPV RX RAW]', raw.trim()); // Very noisy if enabled
this.messageBuffer += raw;
const messages = this.messageBuffer.split('\n');
// The last part might be an incomplete message, save it back to buffer
this.messageBuffer = messages.pop() || '';
for (const msg of messages) {
if (!msg.trim()) continue;
console.log('[IPC]', msg); // User requested raw communication
try {
const json = JSON.parse(msg);
this.emit('message', json);
// Handle specific events
if (json.event) {
this.emit('event', json);
}
} catch (e) {
console.error('Failed to parse MPV message:', msg);
}
}
}
async send(command: any[]) {
if (!this.socket || this.socket.destroyed) {
console.warn('MPV socket not connected');
return;
}
const payload = JSON.stringify({ command });
console.log('[MPV TX]', payload); // User requested raw communication
this.socket.write(payload + '\n');
}
// Convenience methods
async load(url: string) {
return this.send(['loadfile', url]);
}
async play() {
return this.send(['set_property', 'pause', false]);
}
async pause() {
return this.send(['set_property', 'pause', true]);
}
async togglePause() {
return this.send(['cycle', 'pause']);
}
async stop() {
return this.send(['stop']);
}
async setVolume(vol: number) {
return this.send(['set_property', 'volume', vol]);
}
async seek(seconds: number) {
return this.send(['seek', seconds, 'absolute']);
}
destroy() {
if (this.process) {
console.log('Killing MPV process...');
this.process.kill();
this.process = null;
}
if (this.socket) {
this.socket.destroy();
this.socket = null;
}
}
}

View File

@@ -17,5 +17,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
setVolume: (vol: number) => ipcRenderer.invoke('mpv-set-volume', vol),
seek: (time: number) => ipcRenderer.invoke('mpv-seek', time),
onEvent: (callback: (event: any, data: any) => void) => ipcRenderer.on('mpv-event', callback)
},
// Plugin System
plugin: {
call: (pluginId: string, method: string, args: any[]) => ipcRenderer.invoke('plugin:call', pluginId, method, args)
}
})

View File

@@ -8,6 +8,6 @@
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<script type="module" src="/src/renderer/main.ts"></script>
</body>
</html>

64
src/main/pluginSystem.ts Normal file
View File

@@ -0,0 +1,64 @@
import { app } from 'electron'
import path from 'path'
import fs from 'fs'
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
export interface UrlResponse {
success: boolean
url?: string
error?: string
}
type PluginModule = {
getUrl?: (id: string, quality: string) => Promise<UrlResponse> | UrlResponse
}
export class PluginSystem {
private pluginId: string
private plugin: PluginModule | null = null
constructor(pluginId: string) {
this.pluginId = pluginId
this.loadPlugin()
}
private loadPlugin() {
try {
const pluginPath = path.join(
app.getPath('userData'),
'plugins',
this.pluginId,
'index.js'
)
if (!fs.existsSync(pluginPath)) {
throw new Error(`Plugin ${this.pluginId} not found`)
}
delete require.cache[require.resolve(pluginPath)]
this.plugin = require(pluginPath)
} catch (e: any) {
console.error(`[PluginSystem] load failed:`, e)
this.plugin = null
}
}
async getUrl(id: string, quality: string): Promise<UrlResponse> {
if (!this.plugin?.getUrl) {
return {
success: false,
error: 'getUrl not implemented'
}
}
try {
return await this.plugin.getUrl(id, quality)
} catch (e: any) {
return {
success: false,
error: e.message || 'plugin error'
}
}
}
}

View File

@@ -0,0 +1,444 @@
<template>
<transition name="slide-up">
<div class="player-bar" v-if="hasSongs">
<!-- Left: Vinyl & Info -->
<div class="player-left">
<div class="vinyl-wrapper" :class="{ 'playing': isPlaying }">
<img
v-if="currentSong?.picUrl"
:src="currentSong.picUrl"
class="album-art"
alt="Album Art"
/>
<div v-else class="album-placeholder"></div>
<div class="vinyl-bg"></div>
</div>
<div class="track-info">
<div class="track-title-row">
<span class="track-name">{{ currentSong?.name || '未知歌曲' }}</span>
<span class="track-artist"> - {{ currentSong?.artist || '未知歌手' }}</span>
</div>
<div class="track-actions">
<button class="icon-btn tiny" title="添加到歌单">
<Icon icon="lucide:plus-circle" />
</button>
<button class="icon-btn tiny" title="下载">
<Icon icon="lucide:download" />
</button>
</div>
</div>
</div>
<!-- Center: Controls & Progress -->
<div class="player-center">
<div class="controls-row">
<button class="icon-btn small" :class="{ active: isLiked }" @click="toggleLike" title="喜欢">
<Icon :icon="isLiked ? 'lucide:heart' : 'lucide:heart'" :class="{ filled: isLiked }" />
</button>
<button class="icon-btn" @click="prev" title="上一首">
<Icon icon="lucide:skip-back" />
</button>
<button class="play-btn" @click="togglePlay">
<Icon :icon="isPlaying ? 'lucide:pause' : 'lucide:play'" class="play-icon" />
</button>
<button class="icon-btn" @click="next" title="下一首">
<Icon icon="lucide:skip-forward" />
</button>
<button class="icon-btn small" @click="toggleMode" :title="modeTitle">
<Icon :icon="modeIcon" />
</button>
</div>
<div class="progress-row">
<span class="time-text">{{ formatTime(currentTime) }}</span>
<div class="slider-container">
<input
type="range"
min="0"
:max="duration || 100"
:value="currentTime"
class="custom-slider"
@input="onSeek"
>
<div class="slider-track" :style="{ width: progressPercent + '%' }"></div>
</div>
<span class="time-text">{{ formatTime(duration) }}</span>
</div>
</div>
<!-- Right: Volume & Playlist -->
<div class="player-right">
<div class="volume-control">
<button class="icon-btn small" @click="toggleMute">
<Icon :icon="volumeIcon" />
</button>
<div class="slider-container volume-slider">
<input
type="range"
min="0"
max="100"
:value="volume"
class="custom-slider"
@input="onVolumeChange"
>
<div class="slider-track" :style="{ width: volume + '%' }"></div>
</div>
</div>
<div class="divider">|</div>
<button class="icon-btn playlist-toggle" @click="togglePlaylist" title="播放列表">
<Icon icon="lucide:list-music" />
</button>
</div>
</div>
</transition>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { Icon } from '@iconify/vue';
import { usePlayerStore, PlayMode } from '../stores/player';
import { storeToRefs } from 'pinia';
const playerStore = usePlayerStore();
const { isPlaying, currentSong, currentTime, duration, volume, playMode, playlist } = storeToRefs(playerStore);
const hasSongs = computed(() => playlist.value.length > 0);
const isLiked = ref(false); // Mock like state
// Icons
const modeIcon = computed(() => {
switch (playMode.value) {
case PlayMode.Single: return 'lucide:repeat-1';
case PlayMode.Random: return 'lucide:shuffle';
default: return 'lucide:repeat';
}
});
const modeTitle = computed(() => {
switch (playMode.value) {
case PlayMode.Single: return '单曲循环';
case PlayMode.Random: return '随机播放';
default: return '列表循环';
}
});
const volumeIcon = computed(() => {
if (volume.value === 0) return 'lucide:volume-x';
if (volume.value < 50) return 'lucide:volume-1';
return 'lucide:volume-2';
});
// Progress
const progressPercent = computed(() => {
if (!duration.value) return 0;
return (currentTime.value / duration.value) * 100;
});
// Actions
const togglePlay = () => playerStore.togglePlay();
const next = () => playerStore.next();
const prev = () => playerStore.prev();
const toggleMode = () => playerStore.toggleMode();
const toggleLike = () => { isLiked.value = !isLiked.value; };
const togglePlaylist = () => { /* TODO: Toggle Playlist Drawer */ };
const onSeek = (e: Event) => {
const val = Number((e.target as HTMLInputElement).value);
playerStore.seek(val);
};
const onVolumeChange = (e: Event) => {
const val = Number((e.target as HTMLInputElement).value);
playerStore.setVolume(val);
};
const toggleMute = () => {
if (volume.value > 0) playerStore.setVolume(0);
else playerStore.setVolume(50);
};
// Utils
const formatTime = (seconds: number) => {
if (!seconds || isNaN(seconds)) return '00:00';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
};
</script>
<style scoped>
.player-bar {
position: fixed;
bottom: 0;
left: 0;
width: 100%;
height: 80px; /* Established height */
background-color: rgba(24, 24, 24, 0.95); /* Semi-transparent dark bg */
backdrop-filter: blur(20px);
border-top: 1px solid var(--color-border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
z-index: 1000;
box-shadow: 0 -4px 20px rgba(0,0,0,0.4);
}
/* Animations */
.slide-up-enter-active,
.slide-up-leave-active {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
transform: translateY(100%);
opacity: 0;
}
/* --- Left Section --- */
.player-left {
flex: 1;
display: flex;
align-items: center;
gap: 16px;
min-width: 0;
}
.vinyl-wrapper {
position: relative;
width: 64px;
height: 64px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
/* Vinyl Background Simulation or Image */
background: radial-gradient(circle, #1a1a1a 30%, #333 31%, #111 32%, #181818 100%);
box-shadow: 0 4px 10px rgba(0,0,0,0.5);
animation: spin 10s linear infinite;
animation-play-state: paused;
}
.vinyl-wrapper.playing {
animation-play-state: running;
}
/* Use the user specific path if available, or fallback to CSS vinyl look */
.vinyl-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border-radius: 50%;
background-image: url('@/assets/miniYinyl.png'); /* User requested path */
background-size: cover;
opacity: 1;
pointer-events: none;
z-index: 2;
}
.album-art {
width: 51px;
height: 51px;
border-radius: 50%;
object-fit: cover;
z-index: 1;
}
.album-placeholder {
width: 51px;
height: 51px;
border-radius: 50%;
background: #333;
z-index: 1;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.track-info {
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
}
.track-title-row {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-bottom: 4px;
}
.track-name {
font-size: 14px;
color: var(--color-text-primary);
font-weight: 500;
}
.track-artist {
font-size: 12px;
color: var(--color-text-muted);
}
.track-actions {
display: flex;
gap: 8px;
}
/* --- Center Section --- */
.player-center {
flex: 1.5;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
}
.controls-row {
display: flex;
align-items: center;
gap: 20px;
}
.progress-row {
width: 100%;
max-width: 480px;
display: flex;
align-items: center;
gap: 10px;
}
.time-text {
font-size: 11px;
color: var(--color-text-muted);
width: 35px;
text-align: center;
}
/* --- Right Section --- */
.player-right {
flex: 1;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 16px;
}
.volume-control {
display: flex;
align-items: center;
gap: 8px;
width: 120px;
}
.volume-slider {
flex: 1;
}
.divider {
color: var(--color-border);
font-size: 12px;
margin: 0 4px;
}
/* --- Common UI Components --- */
.icon-btn {
background: transparent;
border: none;
color: var(--color-text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
width: 36px;
height: 36px;
transition: all 0.2s;
}
.icon-btn:hover {
color: var(--color-text-primary);
background: var(--color-bg-tertiary);
}
.icon-btn.small { width: 32px; height: 32px; }
.icon-btn.tiny { width: 24px; height: 24px; padding: 0; color: var(--color-text-muted); }
.icon-btn.active { color: var(--color-accent); }
.filled { fill: currentColor; }
.play-btn {
width: 42px;
height: 42px;
border-radius: 50%;
background: var(--color-accent);
color: var(--color-text-primary);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: transform 0.2s;
}
.play-btn:hover {
transform: scale(1.1);
}
/* Custom Slider */
.slider-container {
position: relative;
height: 4px;
flex: 1;
border-radius: 2px;
background: var(--color-bg-tertiary);
cursor: pointer;
display: flex;
align-items: center;
}
.custom-slider {
appearance: none;
position: absolute;
width: 100%;
height: 100%;
background: transparent;
top: 0;
left: 0;
margin: 0;
z-index: 2;
cursor: pointer;
}
.custom-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--color-accent);
opacity: 0;
}
.slider-track {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: var(--color-accent);
border-radius: 2px;
pointer-events: none;
z-index: 1;
}
</style>

View File

@@ -0,0 +1,310 @@
<template>
<aside class="sidebar">
<!-- 顶部区域 -->
<div class="sidebar-header">
<div class="logo-area">
<div class="logo-icon">🎶</div>
<span class="app-name">QZ Music</span>
</div>
</div>
<!-- 主导航 -->
<div class="nav-section">
<router-link to="/" class="nav-item" active-class="active">
<Icon icon="lucide:home" class="nav-icon" />
<span class="nav-text">推荐</span>
</router-link>
<router-link to="/local" class="nav-item" active-class="active">
<Icon icon="lucide:hard-drive" class="nav-icon" />
<span class="nav-text">本地音乐</span>
</router-link>
</div>
<!-- 我的音乐 -->
<div class="divider"></div>
<div class="nav-section">
<div class="section-title">我的音乐</div>
<router-link to="/liked" class="nav-item" active-class="active">
<Icon icon="lucide:heart" class="nav-icon" />
<span class="nav-text">我喜欢的</span>
</router-link>
<router-link to="/recent" class="nav-item" active-class="active">
<Icon icon="lucide:clock" class="nav-icon" />
<span class="nav-text">最近播放</span>
</router-link>
<div class="nav-item">
<Icon icon="lucide:download" class="nav-icon" />
<span class="nav-text">下载管理</span>
</div>
</div>
<!-- 我的歌单 -->
<div class="divider"></div>
<div class="nav-section">
<div class="section-header" @click="togglePlaylists">
<span class="section-title">我的歌单</span>
<Icon
icon="lucide:chevron-down"
class="collapse-icon"
:class="{ 'collapsed': !isPlaylistsOpen }"
/>
</div>
<div class="playlists-list" v-show="isPlaylistsOpen">
<div class="nav-item playlist-item">
<div class="playlist-cover">
<Icon icon="lucide:music" />
</div>
<span class="nav-text">驾驶模式</span>
</div>
<div class="nav-item playlist-item">
<div class="playlist-cover">
<Icon icon="lucide:music" />
</div>
<span class="nav-text">放松时光</span>
</div>
<div class="nav-item playlist-item">
<div class="playlist-cover">
<Icon icon="lucide:music" />
</div>
<span class="nav-text">工作专注</span>
</div>
<div class="nav-item create-playlist">
<Icon icon="lucide:plus" class="nav-icon" />
<span class="nav-text">新建歌单</span>
</div>
</div>
</div>
</aside>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { Icon } from '@iconify/vue';
const isPlaylistsOpen = ref(true);
const togglePlaylists = () => {
isPlaylistsOpen.value = !isPlaylistsOpen.value;
};
</script>
<style scoped>
.sidebar {
width: var(--sidebar-width);
height: 100vh;
background-color: var(--color-bg-secondary);
border-right: 1px solid var(--color-border);
display: flex;
flex-direction: column;
padding: 20px 12px;
overflow-y: auto;
flex-shrink: 0;
}
/* 滚动条样式 */
.sidebar::-webkit-scrollbar {
width: 6px;
}
.sidebar::-webkit-scrollbar-track {
background: transparent;
}
.sidebar::-webkit-scrollbar-thumb {
background: var(--color-border-light);
border-radius: 3px;
}
.sidebar::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted);
}
/* 顶部Logo区域 */
.sidebar-header {
padding: 8px 8px 24px;
margin-bottom: 8px;
}
.logo-area {
display: flex;
align-items: center;
gap: 12px;
padding: 8px;
border-radius: var(--radius-lg);
transition: all var(--transition-base);
}
.logo-area:hover {
background-color: var(--color-bg-tertiary);
}
.logo-icon {
width: 40px;
height: 40px;
background: linear-gradient(135deg, #ec4141, #ff6b6b);
border-radius: var(--radius-lg);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 20px;
font-weight: bold;
box-shadow: var(--shadow-sm);
}
.app-name {
font-size: var(--font-size-lg);
font-weight: 600;
color: var(--color-text-primary);
letter-spacing: -0.02em;
}
/* 导航区域 */
.nav-section {
margin-bottom: 8px;
}
.nav-item {
display: flex;
align-items: center;
padding: 12px 16px;
margin-bottom: 4px;
border-radius: var(--radius-lg);
color: var(--color-text-secondary);
cursor: pointer;
transition: all var(--transition-base);
text-decoration: none;
position: relative;
overflow: hidden;
}
.nav-item:hover {
background-color: var(--color-bg-tertiary);
color: var(--color-text-primary);
transform: translateX(2px);
}
.nav-item.active {
background-color: var(--color-accent-soft);
color: var(--color-accent);
font-weight: 500;
}
.nav-item.active::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 20px;
background-color: var(--color-accent);
border-radius: 0 2px 2px 0;
}
.nav-icon {
width: 20px;
height: 20px;
margin-right: 12px;
flex-shrink: 0;
transition: transform var(--transition-base);
}
.nav-item:hover .nav-icon {
transform: scale(1.1);
}
.nav-text {
font-size: var(--font-size-sm);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
/* 分割线 */
.divider {
height: 1px;
background: linear-gradient(to right, transparent, var(--color-border), transparent);
margin: 16px 8px;
opacity: 0.6;
}
/* 区域标题 */
.section-title {
font-size: var(--font-size-xs);
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 8px 16px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 16px;
margin-bottom: 8px;
cursor: pointer;
color: var(--color-text-muted);
transition: all var(--transition-base);
border-radius: var(--radius-md);
}
.section-header:hover {
color: var(--color-text-primary);
background-color: var(--color-bg-tertiary);
}
.collapse-icon {
transition: transform var(--transition-base);
width: 16px;
height: 16px;
}
.collapse-icon.collapsed {
transform: rotate(-90deg);
}
/* 歌单项 */
.playlist-item {
padding: 10px 16px;
}
.playlist-cover {
width: 36px;
height: 36px;
background: linear-gradient(135deg, #667eea, #764ba2);
border-radius: var(--radius-md);
display: flex;
align-items: center;
justify-content: center;
margin-right: 12px;
color: white;
font-size: 14px;
box-shadow: var(--shadow-sm);
flex-shrink: 0;
}
.playlist-item:hover .playlist-cover {
transform: scale(1.05);
box-shadow: var(--shadow-md);
}
/* 新建歌单 */
.create-playlist {
color: var(--color-text-muted);
margin-top: 8px;
border: 1px dashed var(--color-border-light);
}
.create-playlist:hover {
border-color: var(--color-accent);
color: var(--color-accent);
background-color: var(--color-accent-soft);
}
</style>

View File

@@ -0,0 +1,319 @@
<template>
<header class="topbar">
<div class="left-controls">
<div class="nav-group">
<button class="nav-btn ripple-btn" @click="goBack" title="返回">
<Icon icon="lucide:chevron-left" class="nav-icon" />
</button>
<button class="nav-btn ripple-btn" @click="goForward" title="前进">
<Icon icon="lucide:chevron-right" class="nav-icon" />
</button>
</div>
<div class="search-wrapper">
<div class="search-container">
<Icon icon="lucide:search" class="search-icon" />
<input
type="text"
placeholder="搜索音乐、歌手、专辑..."
class="search-input"
/>
</div>
</div>
</div>
<div class="right-controls">
<div class="app-actions">
<button class="action-btn ripple-btn" title="设置">
<Icon icon="lucide:settings" class="action-icon" />
</button>
</div>
<div class="divider"></div>
<div class="window-actions">
<button class="win-btn minimize" @click="handleMinimize" title="最小化">
<Icon icon="lucide:minus" class="win-icon" />
</button>
<button class="win-btn maximize" @click="handleMaximize" :title="isMaximized ? '还原' : '最大化'">
<Icon :icon="isMaximized ? 'lucide:copy' : 'lucide:square'" class="win-icon" style="transform: scale(0.8);" />
</button>
<button class="win-btn close" @click="handleClose" title="关闭">
<Icon icon="lucide:x" class="win-icon" />
</button>
</div>
</div>
</header>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { Icon } from '@iconify/vue';
const router = useRouter();
const isMaximized = ref(false);
const goBack = () => router.back();
const goForward = () => router.forward();
// --- 窗口控制逻辑 ---
const handleMinimize = () => window.electronAPI?.minimizeWindow();
const handleMaximize = async () => {
window.electronAPI?.maximizeWindow();
isMaximized.value = !isMaximized.value;
checkMaximizedState();
};
const handleClose = () => window.electronAPI?.closeWindow();
const checkMaximizedState = async () => {
if (window.electronAPI) {
setTimeout(async () => {
isMaximized.value = await window.electronAPI!.isMaximized();
}, 100);
}
};
onMounted(() => {
checkMaximizedState();
window.addEventListener('resize', checkMaximizedState);
});
onUnmounted(() => {
window.removeEventListener('resize', checkMaximizedState);
});
</script>
<style scoped>
/* 变量定义 */
:root {
--radius-soft: 10px;
}
.topbar {
height: 64px;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px 0 24px;
background-color: var(--color-bg-primary);
border-bottom: 1px solid var(--color-border);
position: sticky;
top: 0;
z-index: 100;
-webkit-app-region: drag;
user-select: none;
}
/* --- 左侧区域 --- */
.left-controls {
display: flex;
align-items: center;
gap: 16px;
}
.nav-group {
display: flex;
gap: 10px;
}
/* --- 统一功能按钮Nav / Settings--- */
.nav-btn,
.action-btn {
-webkit-app-region: no-drag;
width: 40px;
height: 40px;
border-radius: 10px; /* 圆角边框 */
display: flex;
align-items: center;
justify-content: center;
color: var(--color-text-secondary);
background-color: transparent;
border: 1px solid var(--color-border);
cursor: pointer;
position: relative;
overflow: hidden; /* 关键:裁剪涟漪 */
transition:
background-color 0.2s ease,
border-color 0.2s ease,
color 0.2s ease,
transform 0.12s ease;
}
.nav-btn:hover,
.action-btn:hover {
background-color: var(--color-bg-tertiary);
border-color: var(--color-text-muted);
color: var(--color-text-primary);
}
/* 图标 */
.nav-icon,
.action-icon {
width: 22px;
height: 22px;
z-index: 2;
}
.ripple-btn::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.1); /* 涟漪颜色 */
border-radius: 50%;
transform: translate(-50%, -50%) scale(0); /* 初始不可见 */
opacity: 0;
pointer-events: none;
}
/* 点击瞬间:迅速放大并显示 */
.ripple-btn:active::after {
transform: translate(-50%, -50%) scale(2.5);
opacity: 1;
transition: 0s;
}
/* 松开后:慢慢淡出 */
.ripple-btn:not(:active):after {
transform: translate(-50%, -50%) scale(2.5);
opacity: 0;
transition: opacity 0.4s ease-out;
}
/* --- 搜索框 --- */
.search-wrapper {
-webkit-app-region: no-drag;
}
.search-container {
display: flex;
align-items: center;
height: 40px;
width: 260px;
padding: 0 14px;
background-color: var(--color-bg-tertiary);
border: 1px solid var(--color-border);
border-radius: 20px;
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
cursor: text;
}
.search-container:hover {
border-color: var(--color-text-muted);
}
.search-container:focus-within {
width: 340px;
border-color: var(--color-accent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
background: linear-gradient(
90deg,
var(--color-bg-primary) 0%,
var(--color-bg-tertiary) 100%
);
}
.search-icon {
color: var(--color-text-muted);
margin-right: 10px;
width: 20px;
height: 20px;
flex-shrink: 0;
transition: color 0.2s;
}
.search-container:focus-within .search-icon {
color: var(--color-accent);
}
.search-input {
flex: 1;
height: 100%;
font-size: 15px;
color: var(--color-text-primary);
background: transparent;
border: none;
outline: none;
}
.search-input::placeholder {
color: var(--color-text-muted);
font-size: 14px;
}
/* --- 右侧区域布局调整 --- */
.right-controls {
display: flex;
align-items: center;
gap: 4px; /* 减少间距,让设置按钮更靠近右边 */
height: 100%;
}
.app-actions {
display: flex;
align-items: center;
}
.divider {
width: 1px;
height: 24px;
background-color: var(--color-border);
margin: 0 4px; /* 减少分割线左右的间距 */
}
/* --- 窗口控制区 --- */
.window-actions {
display: flex;
align-items: center;
gap: 6px;
}
.win-btn {
-webkit-app-region: no-drag;
width: 32px;
height: 32px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
background-color: transparent;
color: var(--color-text-primary);
border: none;
cursor: pointer;
transition: all 0.1s;
}
.win-icon {
width: 16px;
height: 16px;
}
.win-btn:not(.close):hover {
background-color: var(--color-bg-tertiary);
}
.win-btn:not(.close):active {
background-color: var(--color-bg-elevated);
}
.win-btn.close:hover {
background-color: #e81123;
color: white;
}
.win-btn.close:active {
background-color: #bf0f1d;
}
</style>

View File

@@ -0,0 +1,99 @@
<template>
<div class="main-layout" :class="{ 'has-player': hasSongs }">
<Sidebar class="layout-sidebar" />
<main class="content-area">
<TopBar />
<div class="page-content">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</div>
</main>
<PlayerBar />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import Sidebar from '../components/Sidebar.vue';
import TopBar from '../components/TopBar.vue';
import PlayerBar from '../components/PlayerBar.vue';
import { usePlayerStore } from '../stores/player.ts';
const playerStore = usePlayerStore();
const hasSongs = computed(() => playerStore.playlist.length > 0);
</script>
<style>
/* 关键修复:全局重置盒模型 */
/* 这确保 width: 100% + padding 不会撑破容器 */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* 隐藏 body 的滚动条,防止整体页面出现原生滚动条 */
body {
overflow: hidden;
}
</style>
<style scoped>
.main-layout {
display: flex;
height: 100vh;
width: 100vw;
background-color: var(--color-bg-primary);
overflow: hidden; /* 确保整个应用不会出现双重滚动条 */
}
/* Dynamic Spacing for PlayerBar */
.layout-sidebar,
.content-area {
transition: padding-bottom 0.3s ease;
}
.main-layout.has-player .layout-sidebar,
.main-layout.has-player .content-area {
padding-bottom: 80px; /* PlayerBar Height */
}
.content-area {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
/* 关键修复:防止 flex 子项内容过宽撑开容器 */
min-width: 0;
}
.page-content {
flex: 1;
overflow-y: auto; /* 只允许内容区域滚动 */
padding: 0;
background-color: var(--color-bg-primary);
position: relative;
}
/* 滚动条样式优化 */
.page-content::-webkit-scrollbar {
width: 8px;
}
.page-content::-webkit-scrollbar-track {
background: transparent;
}
.page-content::-webkit-scrollbar-thumb {
background: var(--color-border-light);
border-radius: 4px;
}
.page-content::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted);
}
</style>

View File

@@ -1,4 +1,4 @@
// src/main.ts
// src/renderer/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { createRouter, createWebHashHistory } from 'vue-router'
@@ -50,13 +50,13 @@ const playerStore = usePlayerStore()
playerStore.playMode = PlayMode.Single;
const testSong: Song = {
id: '9999',
name: 'Test FLAC',
artist: 'Netease',
picUrl: 'http://p1.music.126.net/btYBbFLd5mf9w0lDpfNs6w==/109951171506809884.jpg?param=130y130',
url: 'http://m801.music.126.net/20260202213928/189743bba596f8fd999bc44fd51d11fd/jdymusic/obj/wo3DlMOGwrbDjj7DisKw/61393856655/24be/6a68/77e4/fb898a5378682427bf7a4fb55640e610.flac',
duration: '03:30',
source: 'netease',
id: '3337983421',
name: '不死身ごっこ (feat. 初音ミク)',
artist: 'ピノキオピー、初音ミク',
picUrl: 'http://p2.music.126.net/7O6FcCraxldhFGz4CSPVlw==/109951172567380787.jpg?imageView=&thumbnail=371y371&type=webp&rotate=360&tostatic=0',
url: 'http://m701.music.126.net/20260203120731/1480f3bdda9795d5e7cc25af304b6cae/jdymusic/obj/wo3DlMOGwrbDjj7DisKw/77635439540/89c6/162f/e373/62ded4a27758346e53f717f46ba2802c.flac',
duration: '02:31',
source: 'wy',
type: 'Remote'
};

View File

@@ -0,0 +1,296 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { MessagePlugin } from 'tdesign-vue-next';
import type { Song } from '../types/song';
export enum PlayMode {
List = 'list',
Single = 'single',
Random = 'random'
}
// 静音 WAV Base64
const SILENT_AUDIO_URL = 'data:audio/wav;base64,UklGRigAAABXQVZFZm10IBIAAAABAAEARKwAAIhYAQACABAAAABkYXRhAgAAAAEA';
const dummyAudio = new Audio();
dummyAudio.src = SILENT_AUDIO_URL;
dummyAudio.loop = true;
dummyAudio.volume = 0.01;
if (typeof document !== 'undefined') {
document.body.appendChild(dummyAudio);
}
export const usePlayerStore = defineStore('player', () => {
// State
const isPlaying = ref(false);
const currentSong = ref<Song | null>(null);
const volume = ref(100);
const duration = ref(0);
const currentTime = ref(0);
// Playlist State
const playlist = ref<Song[]>([]);
const currentIndex = ref(-1);
const playMode = ref<PlayMode>(PlayMode.List);
// Error Handling
const playErrorCount = ref(0);
const MAX_RETRY_COUNT = 3;
// --- Helpers ---
const activateDummyAudio = async () => {
if (dummyAudio.paused) {
try {
await dummyAudio.play().catch(e => console.warn('Dummy play failed:', e));
} catch (e) {
// Ignore
}
}
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = 'playing';
}
};
// 同步浏览器状态
const syncDummyAudioState = (shouldPlay: boolean) => {
if (shouldPlay) {
if (dummyAudio.paused) {
dummyAudio.play().catch(() => { });
}
if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'playing';
} else {
if (!dummyAudio.paused) {
dummyAudio.pause();
}
if ('mediaSession' in navigator) navigator.mediaSession.playbackState = 'paused';
}
};
// --- Actions ---
// Cache: key = `${source}:${id}:${quality}`
const urlCache = new Map<string, string>();
const fetchUrl = async (song: Song, forceRefresh = false): Promise<string | null> => {
if (!song.source || !song.id) return null;
const quality = 'hires'; // TODO: Make configurable
const cacheKey = `${song.source}:${song.id}:${quality}`;
if (!forceRefresh && urlCache.has(cacheKey)) {
console.log(`[Cache] Hit for ${song.name}`);
return urlCache.get(cacheKey) || null;
}
console.log(`[Plugin] Fetching URL for [${song.name}] from ${song.source}...`);
try {
const result = await window.electronAPI.plugin.call(song.source, 'getUrl', [song.id, quality]);
if (result.success && result.url) {
console.log('[Plugin] Success:', result.url);
urlCache.set(cacheKey, result.url);
return result.url;
} else {
console.error('[Plugin] Failed:', result.error);
if (!forceRefresh) MessagePlugin.error(`无法获取播放链接: ${result.error || '未知错误'}`);
return null;
}
} catch (e) {
console.error('[Plugin] Error:', e);
return null;
}
};
const setPlaylist = async (list: any[], startIndex = 0) => {
playlist.value = list;
currentIndex.value = startIndex;
if (list.length > 0 && startIndex >= 0 && startIndex < list.length) {
await playSong(list[startIndex]);
}
};
const playSong = async (song: Song) => {
if (!song) return;
currentSong.value = song;
const foundIndex = playlist.value.findIndex(s => s.id === song.id);
if (foundIndex !== -1) {
currentIndex.value = foundIndex;
}
await activateDummyAudio();
updateMediaSession(song);
// 1. Get URL (Cache -> Network)
let playUrl = song.url;
if (song.type === 'Remote' && song.source) {
const fetched = await fetchUrl(song);
if (fetched) playUrl = fetched;
}
if (playUrl) {
console.log('Playing:', song.name);
try {
await window.electronAPI.mpv.load(playUrl);
await window.electronAPI.mpv.play();
isPlaying.value = true;
// Update song object URL for UI reference if needed
song.url = playUrl;
} catch (e) {
console.error("Play request failed:", e);
// Retry Logic: If Remote and failed, force refresh URL and retry
if (song.type === 'Remote' && song.source) {
console.warn("Playback failed. Retrying with fresh URL...");
const freshUrl = await fetchUrl(song, true); // Force Refresh
if (freshUrl) {
try {
await window.electronAPI.mpv.load(freshUrl);
await window.electronAPI.mpv.play();
isPlaying.value = true;
song.url = freshUrl;
return; // Success on retry
} catch (retryError) {
console.error("Retry playback failed:", retryError);
}
}
}
handlePlayError();
}
} else {
console.warn("Song has no URL");
handlePlayError();
}
};
const updateMediaSession = (song: Song) => {
if (!('mediaSession' in navigator)) return;
navigator.mediaSession.metadata = new MediaMetadata({
title: song.name,
artist: song.artist,
album: song.albumName || '',
artwork: song.picUrl ? [{ src: song.picUrl, sizes: '512x512', type: 'image/png' }] : []
});
navigator.mediaSession.setActionHandler('play', () => window.electronAPI.mpv.play());
navigator.mediaSession.setActionHandler('pause', () => window.electronAPI.mpv.pause());
navigator.mediaSession.setActionHandler('previoustrack', () => prev());
navigator.mediaSession.setActionHandler('nexttrack', () => next(true));
navigator.mediaSession.setActionHandler('seekto', (details) => {
if (details.seekTime != null) {
seek(details.seekTime);
}
});
};
const next = async (manual = true) => {
if (playlist.value.length === 0) return;
let nextIndex = currentIndex.value;
if (playMode.value === PlayMode.Single && !manual) {
nextIndex = currentIndex.value;
} else if (playMode.value === PlayMode.Random) {
nextIndex = Math.floor(Math.random() * playlist.value.length);
} else {
nextIndex = (currentIndex.value + 1) % playlist.value.length;
}
currentIndex.value = nextIndex;
await playSong(playlist.value[nextIndex]);
};
const prev = async () => {
if (playlist.value.length === 0) return;
let prevIndex = currentIndex.value;
if (playMode.value === PlayMode.Random) {
prevIndex = Math.floor(Math.random() * playlist.value.length);
} else {
prevIndex = (currentIndex.value - 1 + playlist.value.length) % playlist.value.length;
}
currentIndex.value = prevIndex;
await playSong(playlist.value[prevIndex]);
};
const handlePlayError = () => {
playErrorCount.value++;
if (playlist.value.length === 0) {
isPlaying.value = false;
playErrorCount.value = 0;
syncDummyAudioState(false);
return;
}
if (playErrorCount.value >= MAX_RETRY_COUNT) {
window.electronAPI.mpv.pause();
isPlaying.value = false;
MessagePlugin.error('连续多次播放失败,已停止播放');
playErrorCount.value = 0;
syncDummyAudioState(false);
} else {
MessagePlugin.warning(`播放失败,尝试播放下一首 (${playErrorCount.value}/${MAX_RETRY_COUNT})`);
setTimeout(() => next(false), 1000);
}
};
// Listeners
if (window.electronAPI) {
window.electronAPI.mpv.onEvent((_event, data) => {
if (data.event === 'property-change') {
if (data.name === 'pause') {
const isPaused = data.data;
isPlaying.value = !isPaused;
// 核心MPV 暂停 -> 同步暂停 Dummy -> 浏览器更新 SMTC 状态
syncDummyAudioState(!isPaused);
}
if (data.name === 'time-pos') currentTime.value = data.data;
if (data.name === 'duration') duration.value = data.data;
}
if (data.event === 'end-file') {
const reason = data.reason;
if (reason === 'eof') {
next(false);
} else if (reason === 'error') {
handlePlayError();
}
}
});
}
const togglePlay = async () => {
await window.electronAPI.mpv.togglePause();
};
const setVolume = async (vol: number) => {
volume.value = vol;
await window.electronAPI.mpv.setVolume(vol);
};
const seek = async (time: number) => {
await window.electronAPI.mpv.seek(time);
};
const toggleMode = () => {
if (playMode.value === PlayMode.List) playMode.value = PlayMode.Single;
else if (playMode.value === PlayMode.Single) playMode.value = PlayMode.Random;
else playMode.value = PlayMode.List;
};
return {
isPlaying,
currentSong,
volume,
duration,
currentTime,
playlist,
playMode,
setPlaylist,
playSong,
next,
prev,
togglePlay,
setVolume,
seek,
toggleMode
};
});

View File

@@ -0,0 +1,60 @@
@import 'variables.css';
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font-family-base);
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow: hidden;
/* App-like feel */
user-select: none;
/* Prevent text selection generally in app */
}
a {
text-decoration: none;
color: inherit;
}
button {
border: none;
background: none;
cursor: pointer;
font-family: inherit;
color: inherit;
}
input {
border: none;
background: none;
font-family: inherit;
color: inherit;
outline: none;
}
/* Custom Scrollbar for 'Exquisite' look */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-bg-tertiary);
border-radius: var(--radius-full);
-electron-corner-smoothing: 65%;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted);
}

View File

@@ -0,0 +1,49 @@
:root {
/* Colors - 网易云风格配色 */
--color-bg-primary: #121212;
--color-bg-secondary: #181818;
--color-bg-tertiary: #282828;
--color-bg-elevated: #2a2a2a;
--color-text-primary: #ffffff;
--color-text-secondary: #b3b3b3;
--color-text-muted: #737373;
--color-accent: #ec4141;
--color-accent-hover: #ff5555;
--color-accent-soft: rgba(236, 65, 65, 0.1);
--color-border: #2a2a2a;
--color-border-light: #3a3a3a;
/* Shadows - 柔和阴影效果 */
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.12);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.16);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.24);
--shadow-elevated: 0 12px 48px rgba(0, 0, 0, 0.32);
/* Typography */
--font-family-base: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
/* Spacing & Radius - 网易云风格大圆角 */
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 20px;
--radius-xl: 24px;
--radius-2xl: 32px;
--radius-full: 9999px;
--sidebar-width: 240px;
--topbar-height: 64px;
/* Transitions */
--transition-fast: 0.15s ease;
--transition-base: 0.25s ease;
--transition-slow: 0.35s ease;
}

View File

@@ -12,7 +12,10 @@ export interface IElectronAPI {
setVolume: (vol: number) => Promise<void>;
seek: (time: number) => Promise<void>;
onEvent: (callback: (event: any, data: any) => void) => void;
}
};
plugin: {
call: (pluginId: string, method: string, args: any[]) => Promise<any>;
};
}
declare global {

View File

@@ -0,0 +1,24 @@
// Song Type Definition
export type SongType = 'Local' | 'Remote';
export interface SongQualityMap {
[quality: string]: string; // e.g., "standard": "5.5M", "exhigh": "8.0M"
}
export interface Song {
id: string;
hash?: string | null;
picUrl: string;
url: string;
name: string;
artist: string;
duration: string;
source: string;
quality?: string; // default 'auto'
albumId?: string | null;
albumName?: string | null;
artistIds?: string[] | null;
type: SongType;
types?: SongQualityMap;
}

423
src/renderer/views/Home.vue Normal file
View File

@@ -0,0 +1,423 @@
<template>
<div class="view-container home-view">
<div class="content-wrapper">
<!-- 每日推荐横幅 -->
<div class="daily-recommend">
<div class="banner-content">
<div class="date-badge">
<div class="day">{{ currentDate.day }}</div>
<div class="month">{{ currentDate.month }}</div>
</div>
<div class="banner-info">
<h2 class="banner-title">每日推荐</h2>
<p class="banner-desc">根据你的音乐口味为你精选30首歌曲</p>
<button class="play-btn">
<Icon icon="lucide:play" class="play-icon" />
立即播放
</button>
</div>
</div>
</div>
<!-- 推荐歌单 -->
<div class="section">
<div class="section-header">
<h3 class="section-title">推荐歌单</h3>
<button class="more-btn">更多</button>
</div>
<div class="playlist-grid">
<div class="playlist-card" v-for="i in 6" :key="i">
<div class="playlist-cover">
<div class="cover-gradient"></div>
<div class="play-overlay">
<Icon icon="lucide:play" class="overlay-icon" />
</div>
</div>
<div class="playlist-info">
<h4 class="playlist-name">精选歌单 {{ i }}</h4>
<p class="playlist-desc">30首歌曲</p>
</div>
</div>
</div>
</div>
<!-- 热门歌手 -->
<div class="section">
<div class="section-header">
<h3 class="section-title">热门歌手</h3>
<button class="more-btn">更多</button>
</div>
<div class="artist-grid">
<div class="artist-card" v-for="i in 8" :key="i">
<div class="artist-avatar">
<div class="avatar-gradient"></div>
</div>
<p class="artist-name">歌手 {{ i }}</p>
</div>
</div>
</div>
<!-- 新歌速递 -->
<div class="section">
<div class="section-header">
<h3 class="section-title">新歌速递</h3>
<button class="more-btn">播放全部</button>
</div>
<div class="song-list">
<div class="song-item" v-for="i in 10" :key="i">
<div class="song-index">{{ i }}</div>
<div class="song-cover">
<div class="cover-gradient"></div>
</div>
<div class="song-info">
<h4 class="song-title">歌曲名称 {{ i }}</h4>
<p class="song-artist">歌手名称</p>
</div>
<div class="song-duration">03:45</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { Icon } from '@iconify/vue';
const currentDate = computed(() => {
const now = new Date();
return {
day: now.getDate(),
month: now.getMonth() + 1
};
});
</script>
<style scoped>
.home-view {
width: 100%;
height: 100%;
}
.content-wrapper {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
/* 每日推荐横幅 */
.daily-recommend {
margin-bottom: 40px;
}
.banner-content {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: var(--radius-2xl);
-electron-corner-smoothing: 65%;
padding: 40px;
display: flex;
align-items: center;
gap: 32px;
box-shadow: var(--shadow-lg);
position: relative;
overflow: hidden;
}
.banner-content::before {
content: '';
position: absolute;
top: -50%;
right: -20%;
width: 400px;
height: 400px;
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 70%);
border-radius: 50%;
}
.date-badge {
background: rgba(255, 255, 255, 0.15);
backdrop-filter: blur(10px);
border-radius: var(--radius-xl);
padding: 16px 20px;
text-align: center;
min-width: 100px;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.day {
font-size: 48px;
font-weight: 700;
color: white;
line-height: 1;
}
.month {
font-size: var(--font-size-lg);
color: rgba(255, 255, 255, 0.8);
margin-top: 4px;
}
.banner-info {
flex: 1;
}
.banner-title {
font-size: var(--font-size-2xl);
font-weight: 700;
color: white;
margin-bottom: 12px;
}
.banner-desc {
color: rgba(255, 255, 255, 0.8);
font-size: var(--font-size-base);
margin-bottom: 24px;
}
.play-btn {
display: inline-flex;
align-items: center;
gap: 8px;
background: white;
color: #667eea;
padding: 12px 32px;
border-radius: var(--radius-full);
border: none;
font-size: var(--font-size-base);
font-weight: 600;
cursor: pointer;
transition: all var(--transition-base);
box-shadow: var(--shadow-md);
}
.play-btn:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
}
.play-icon {
width: 18px;
height: 18px;
}
/* 区域样式 */
.section {
margin-bottom: 48px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.section-title {
font-size: var(--font-size-xl);
font-weight: 600;
color: var(--color-text-primary);
}
.more-btn {
background: transparent;
border: 1px solid var(--color-border-light);
color: var(--color-text-secondary);
padding: 8px 20px;
border-radius: var(--radius-full);
font-size: var(--font-size-sm);
cursor: pointer;
transition: all var(--transition-base);
}
.more-btn:hover {
border-color: var(--color-accent);
color: var(--color-accent);
background-color: var(--color-accent-soft);
}
/* 歌单网格 */
.playlist-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 20px;
}
.playlist-card {
cursor: pointer;
transition: transform var(--transition-base);
}
.playlist-card:hover {
transform: translateY(-4px);
}
.playlist-cover {
position: relative;
aspect-ratio: 1;
border-radius: var(--radius-lg);
overflow: hidden;
margin-bottom: 12px;
background: var(--color-bg-tertiary);
}
.cover-gradient {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
}
.play-overlay {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.4);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity var(--transition-base);
}
.playlist-card:hover .play-overlay {
opacity: 1;
}
.overlay-icon {
width: 48px;
height: 48px;
color: white;
transform: scale(0.8);
transition: transform var(--transition-base);
}
.playlist-card:hover .overlay-icon {
transform: scale(1);
}
.playlist-info {
padding: 4px 0;
}
.playlist-name {
font-size: var(--font-size-base);
font-weight: 500;
color: var(--color-text-primary);
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.playlist-desc {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
}
/* 歌手网格 */
.artist-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 24px;
}
.artist-card {
text-align: center;
cursor: pointer;
transition: transform var(--transition-base);
}
.artist-card:hover {
transform: translateY(-4px);
}
.artist-avatar {
width: 120px;
height: 120px;
border-radius: var(--radius-full);
overflow: hidden;
margin: 0 auto 12px;
background: var(--color-bg-tertiary);
}
.avatar-gradient {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
}
.artist-name {
font-size: var(--font-size-sm);
color: var(--color-text-primary);
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 歌曲列表 */
.song-list {
background-color: var(--color-bg-secondary);
border-radius: var(--radius-xl);
padding: 8px;
}
.song-item {
display: flex;
align-items: center;
padding: 12px 16px;
border-radius: var(--radius-lg);
transition: all var(--transition-base);
cursor: pointer;
gap: 16px;
}
.song-item:hover {
background-color: var(--color-bg-tertiary);
}
.song-index {
width: 24px;
text-align: center;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
flex-shrink: 0;
}
.song-cover {
width: 48px;
height: 48px;
border-radius: var(--radius-md);
overflow: hidden;
flex-shrink: 0;
background: var(--color-bg-tertiary);
}
.song-info {
flex: 1;
min-width: 0;
}
.song-title {
font-size: var(--font-size-base);
font-weight: 500;
color: var(--color-text-primary);
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.song-artist {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.song-duration {
font-size: var(--font-size-sm);
color: var(--color-text-muted);
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,54 @@
<template>
<div class="view-container local-view">
<h1 class="view-title">Local Files</h1>
<div class="empty-state">
<div class="icon-box">
<Icon icon="lucide:music" width="48" height="48" />
</div>
<p>No local files scanned yet.</p>
<button class="action-btn">Scan Folder</button>
</div>
</div>
</template>
<script setup lang="ts">
import { Icon } from '@iconify/vue';
</script>
<style scoped>
.view-title {
font-size: 2rem;
font-weight: 700;
margin-bottom: 24px;
}
.empty-state {
height: 400px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--color-text-muted);
}
.icon-box {
background-color: var(--color-bg-secondary);
padding: 24px;
border-radius: 50%;
margin-bottom: 24px;
}
.action-btn {
margin-top: 24px;
background-color: var(--color-accent);
color: var(--color-bg-primary);
padding: 12px 24px;
border-radius: var(--radius-full);
font-weight: 600;
transition: opacity 0.2s;
}
.action-btn:hover {
opacity: 0.9;
}
</style>

View File

@@ -0,0 +1,465 @@
<template>
<div class="view-container playlist-view">
<div class="content-wrapper">
<!-- 动态头部设计 -->
<div class="playlist-header">
<div class="header-bg" :class="themeClass">
<div class="flow-circle c1"></div>
<div class="flow-circle c2"></div>
</div>
<div class="header-content">
<div class="cover-box" :style="{ background: coverGradient }">
<div class="icon-wrapper">
<Icon :icon="iconName" class="big-icon" />
</div>
</div>
<div class="info-box">
<div class="sub-title">PLAYLIST</div>
<h1 class="title">{{ title }}</h1>
<div class="meta-info">
<div class="avatar-row">
<div class="user-avatar">
<Icon icon="lucide:user" />
</div>
<span class="user-name">User</span>
</div>
<span class="divider"></span>
<span class="count">{{ songCount }} 首歌曲</span>
</div>
<div class="action-row">
<button class="play-all-btn" :class="themeClass" @click="handlePlayAll">
<Icon icon="lucide:play" class="btn-icon" />
播放全部
</button>
<button class="action-btn">
<Icon icon="lucide:download" />
</button>
<button class="action-btn">
<Icon icon="lucide:share-2" />
</button>
</div>
</div>
</div>
</div>
<!-- 歌曲列表 -->
<div class="song-list-container">
<div class="list-header">
<div class="col-index">#</div>
<div class="col-title">标题</div>
<div class="col-album">专辑</div>
<div class="col-time">时长</div>
</div>
<div class="song-list">
<div class="song-item" v-for="(song, i) in songs" :key="song.id" @dblclick="handlePlaySong(i)">
<div class="song-index">{{ i + 1 }}</div>
<div class="song-cover">
<div class="cover-gradient" :class="themeClass"></div>
</div>
<div class="song-info">
<h4 class="song-title">{{ song.title }}</h4>
<p class="song-artist">{{ song.artist }}</p>
</div>
<div class="song-album">{{ song.album }}</div>
<div class="song-duration">{{ song.duration }}</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { Icon } from '@iconify/vue';
import { usePlayerStore } from '../stores/player.ts';
const route = useRoute();
const playerStore = usePlayerStore();
// 判断页面类型
const isLiked = computed(() => route.path.includes('liked'));
const isRecent = computed(() => route.path.includes('recent'));
// 动态数据
const title = computed(() => {
if (isLiked.value) return '我喜欢的音乐';
if (isRecent.value) return '最近播放';
return '歌单';
});
const iconName = computed(() => {
if (isLiked.value) return 'lucide:heart';
if (isRecent.value) return 'lucide:clock';
return 'lucide:music';
});
const themeClass = computed(() => {
if (isLiked.value) return 'theme-liked';
if (isRecent.value) return 'theme-recent';
return 'theme-default';
});
const coverGradient = computed(() => {
if (isLiked.value) return 'linear-gradient(135deg, #ff9a9e 0%, #fecfef 99%, #fecfef 100%)';
if (isRecent.value) return 'linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%)';
return 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)';
});
// Mock Playlist Data
const songs = ref(Array.from({ length: 15 }, (_, i) => ({
id: i + 1,
title: `Song Title ${i + 1}`,
artist: `Artist Name ${i + 1}`,
album: `Album Mock`,
duration: '03:30',
url: 'http://commondatastorage.googleapis.com/codeskulptor-demos/riceracer_assets/music/win.ogg'
})));
const songCount = computed(() => songs.value.length);
const handlePlayAll = () => {
playerStore.setPlaylist(songs.value);
};
const handlePlaySong = (index: number) => {
playerStore.setPlaylist(songs.value, index);
};
</script>
<style scoped>
.playlist-view {
width: 100%;
height: 100%;
overflow-y: auto;
}
.content-wrapper {
padding: 30px;
max-width: 1400px;
margin: 0 auto;
}
/* Header Styles */
.playlist-header {
position: relative;
height: 240px;
border-radius: var(--radius-2xl);
overflow: hidden;
margin-bottom: 30px;
display: flex;
align-items: center;
padding: 0 40px;
box-shadow: var(--shadow-lg);
}
.header-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-size: 400% 400%;
animation: gradientBG 15s ease infinite;
z-index: 1;
}
/* Default Dark Dynamic */
.header-bg {
background-image: linear-gradient(-45deg, #1e1e1e, #2a2a2a, #3a1c1c, #1a1a1a);
}
.header-bg.theme-liked {
background-image: linear-gradient(-45deg, #2a1a1a, #4a2c2c, #3a1c1c, #1a1a1a);
}
.header-bg.theme-recent {
background-image: linear-gradient(-45deg, #1a1a2a, #2c2c4a, #1c1c3a, #1a1a1a);
}
@keyframes gradientBG {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.flow-circle {
position: absolute;
border-radius: 50%;
filter: blur(60px);
opacity: 0.4;
animation: float 10s infinite ease-in-out;
}
.c1 {
width: 300px;
height: 300px;
background: #ec4141; /* Default/Liked Color */
top: -50px;
left: -50px;
animation-delay: 0s;
}
.c2 {
width: 400px;
height: 400px;
background: #4facfe;
bottom: -100px;
right: -50px;
animation-delay: -5s;
}
/* Recent Theme Colors */
.theme-recent .c1 { background: #a18cd1; }
.theme-recent .c2 { background: #fbc2eb; }
@keyframes float {
0%, 100% { transform: translateY(0) scale(1); }
50% { transform: translateY(20px) scale(1.1); }
}
.header-content {
position: relative;
z-index: 2;
display: flex;
gap: 32px;
align-items: center;
width: 100%;
}
.cover-box {
width: 160px;
height: 160px;
border-radius: var(--radius-xl);
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
}
.icon-wrapper {
background: rgba(255,255,255,0.2);
width: 80px;
height: 80px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
}
.big-icon {
color: #fff;
width: 40px;
height: 40px;
fill: currentColor;
}
.info-box {
flex: 1;
color: white;
display: flex;
flex-direction: column;
justify-content: center;
}
.sub-title {
font-size: 12px;
letter-spacing: 2px;
opacity: 0.8;
margin-bottom: 8px;
}
.title {
font-size: 42px;
font-weight: 800;
margin-bottom: 16px;
letter-spacing: -1px;
}
.meta-info {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 24px;
font-size: 14px;
color: rgba(255,255,255,0.8);
}
.avatar-row {
display: flex;
align-items: center;
gap: 8px;
}
.user-avatar {
width: 24px;
height: 24px;
background: #555;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.divider {
opacity: 0.4;
}
.action-row {
display: flex;
gap: 12px;
}
.play-all-btn {
background: #ec4141; /* Default */
color: white;
border: none;
padding: 10px 24px;
border-radius: var(--radius-full);
font-size: 16px;
font-weight: 600;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
transition: all 0.2s;
}
.play-all-btn.theme-recent {
background: #a18cd1;
}
.play-all-btn:hover {
transform: scale(1.05);
filter: brightness(1.1);
}
.action-btn {
width: 40px;
height: 40px;
border-radius: 50%;
border: 1px solid rgba(255,255,255,0.2);
background: transparent;
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.action-btn:hover {
background: rgba(255,255,255,0.1);
border-color: white;
}
/* List Styles */
.song-list-container {
}
.list-header {
display: flex;
padding: 0 16px;
margin-bottom: 8px;
color: var(--color-text-muted);
font-size: 13px;
}
.col-index { width: 40px; text-align: center; }
.col-title { flex: 1; }
.col-album { width: 200px; }
.col-time { width: 60px; text-align: right; }
.song-list {
}
.song-item {
display: flex;
align-items: center;
padding: 10px 16px;
border-radius: var(--radius-lg);
transition: background-color 0.2s;
cursor: pointer;
color: var(--color-text-secondary);
}
.song-item:hover {
background-color: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.song-item:hover .song-index {
color: var(--color-text-primary);
}
.song-index {
width: 40px;
text-align: center;
font-size: 14px;
color: var(--color-text-muted);
}
.song-cover {
width: 40px;
height: 40px;
border-radius: 6px;
margin-right: 16px;
background: #333;
overflow: hidden;
}
.cover-gradient {
width: 100%;
height: 100%;
background: linear-gradient(45deg, #667eea, #764ba2);
}
/* Different gradients for list items too, maybe? */
.cover-gradient.theme-liked {
background: linear-gradient(45deg, #ff9a9e, #fecfef);
}
.cover-gradient.theme-recent {
background: linear-gradient(45deg, #a18cd1, #fbc2eb);
}
.song-info {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.song-title {
font-size: 15px;
color: var(--color-text-primary);
margin-bottom: 2px;
}
.song-artist {
font-size: 12px;
color: var(--color-text-muted);
}
.song-album {
width: 200px;
font-size: 13px;
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.song-duration {
width: 60px;
text-align: right;
font-size: 13px;
color: var(--color-text-muted);
}
</style>