From 935038cd93a59d01bb69216ee30eb7fb81738275 Mon Sep 17 00:00:00 2001 From: lqtmcstudio Date: Tue, 3 Feb 2026 12:59:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=8A=9F=E8=83=BD&?= =?UTF-8?q?=E4=BC=98=E5=8C=96=20-=20=E5=BA=95=E9=83=A8=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E6=A0=8F=20-=20MediaSession=20-=20=E6=8F=92=E4=BB=B6=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F&=E5=AD=98=E5=82=A8=E4=BD=8D=E7=BD=AE=20-=20URL?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E6=9C=BA=E5=88=B6=20-=20=E6=95=B4=E7=90=86?= =?UTF-8?q?=E9=A1=B9=E7=9B=AE=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dist-electron/main.js | 87 ++++- dist-electron/preload.mjs | 4 + electron/main.ts | 51 ++- electron/mpvController.ts | 173 +++++++++ electron/preload.ts | 5 + index.html | 2 +- src/main/pluginSystem.ts | 64 ++++ src/{ => renderer}/App.vue | 0 src/renderer/components/PlayerBar.vue | 444 +++++++++++++++++++++++ src/renderer/components/Sidebar.vue | 310 +++++++++++++++++ src/renderer/components/TopBar.vue | 319 +++++++++++++++++ src/renderer/layout/MainLayout.vue | 99 ++++++ src/{ => renderer}/main.ts | 16 +- src/renderer/stores/player.ts | 296 ++++++++++++++++ src/renderer/styles/main.css | 60 ++++ src/renderer/styles/variables.css | 49 +++ src/{ => renderer}/types/electron.d.ts | 5 +- src/renderer/types/song.ts | 24 ++ src/renderer/views/Home.vue | 423 ++++++++++++++++++++++ src/renderer/views/LocalMusic.vue | 54 +++ src/renderer/views/Playlist.vue | 465 +++++++++++++++++++++++++ 21 files changed, 2935 insertions(+), 15 deletions(-) create mode 100644 electron/mpvController.ts create mode 100644 src/main/pluginSystem.ts rename src/{ => renderer}/App.vue (100%) create mode 100644 src/renderer/components/PlayerBar.vue create mode 100644 src/renderer/components/Sidebar.vue create mode 100644 src/renderer/components/TopBar.vue create mode 100644 src/renderer/layout/MainLayout.vue rename src/{ => renderer}/main.ts (71%) create mode 100644 src/renderer/stores/player.ts create mode 100644 src/renderer/styles/main.css create mode 100644 src/renderer/styles/variables.css rename src/{ => renderer}/types/electron.d.ts (85%) create mode 100644 src/renderer/types/song.ts create mode 100644 src/renderer/views/Home.vue create mode 100644 src/renderer/views/LocalMusic.vue create mode 100644 src/renderer/views/Playlist.vue diff --git a/dist-electron/main.js b/dist-electron/main.js index 0fa7604..d64fe58 100644 --- a/dist-electron/main.js +++ b/dist-electron/main.js @@ -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(); diff --git a/dist-electron/preload.mjs b/dist-electron/preload.mjs index 68159f0..747815e 100644 --- a/dist-electron/preload.mjs +++ b/dist-electron/preload.mjs @@ -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) } }); diff --git a/electron/main.ts b/electron/main.ts index 3b43874..5957be2 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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() diff --git a/electron/mpvController.ts b/electron/mpvController.ts new file mode 100644 index 0000000..ef28a1a --- /dev/null +++ b/electron/mpvController.ts @@ -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; + } + } +} diff --git a/electron/preload.ts b/electron/preload.ts index 6756d68..cdea379 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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) } }) \ No newline at end of file diff --git a/index.html b/index.html index dde16aa..ba7d41f 100644 --- a/index.html +++ b/index.html @@ -8,6 +8,6 @@
- + diff --git a/src/main/pluginSystem.ts b/src/main/pluginSystem.ts new file mode 100644 index 0000000..a6c2776 --- /dev/null +++ b/src/main/pluginSystem.ts @@ -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 +} + +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 { + 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' + } + } + } +} \ No newline at end of file diff --git a/src/App.vue b/src/renderer/App.vue similarity index 100% rename from src/App.vue rename to src/renderer/App.vue diff --git a/src/renderer/components/PlayerBar.vue b/src/renderer/components/PlayerBar.vue new file mode 100644 index 0000000..e3da47a --- /dev/null +++ b/src/renderer/components/PlayerBar.vue @@ -0,0 +1,444 @@ + + + + + diff --git a/src/renderer/components/Sidebar.vue b/src/renderer/components/Sidebar.vue new file mode 100644 index 0000000..9527613 --- /dev/null +++ b/src/renderer/components/Sidebar.vue @@ -0,0 +1,310 @@ + + + + + diff --git a/src/renderer/components/TopBar.vue b/src/renderer/components/TopBar.vue new file mode 100644 index 0000000..f1af550 --- /dev/null +++ b/src/renderer/components/TopBar.vue @@ -0,0 +1,319 @@ + + + + + \ No newline at end of file diff --git a/src/renderer/layout/MainLayout.vue b/src/renderer/layout/MainLayout.vue new file mode 100644 index 0000000..aa6c34c --- /dev/null +++ b/src/renderer/layout/MainLayout.vue @@ -0,0 +1,99 @@ + + + + + + + \ No newline at end of file diff --git a/src/main.ts b/src/renderer/main.ts similarity index 71% rename from src/main.ts rename to src/renderer/main.ts index b180074..fbbb50a 100644 --- a/src/main.ts +++ b/src/renderer/main.ts @@ -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' }; diff --git a/src/renderer/stores/player.ts b/src/renderer/stores/player.ts new file mode 100644 index 0000000..d3befa2 --- /dev/null +++ b/src/renderer/stores/player.ts @@ -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(null); + const volume = ref(100); + const duration = ref(0); + const currentTime = ref(0); + + // Playlist State + const playlist = ref([]); + const currentIndex = ref(-1); + const playMode = ref(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(); + + const fetchUrl = async (song: Song, forceRefresh = false): Promise => { + 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 + }; +}); \ No newline at end of file diff --git a/src/renderer/styles/main.css b/src/renderer/styles/main.css new file mode 100644 index 0000000..2270ce9 --- /dev/null +++ b/src/renderer/styles/main.css @@ -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); +} \ No newline at end of file diff --git a/src/renderer/styles/variables.css b/src/renderer/styles/variables.css new file mode 100644 index 0000000..29074bd --- /dev/null +++ b/src/renderer/styles/variables.css @@ -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; +} diff --git a/src/types/electron.d.ts b/src/renderer/types/electron.d.ts similarity index 85% rename from src/types/electron.d.ts rename to src/renderer/types/electron.d.ts index 5de43b5..76533bb 100644 --- a/src/types/electron.d.ts +++ b/src/renderer/types/electron.d.ts @@ -12,7 +12,10 @@ export interface IElectronAPI { setVolume: (vol: number) => Promise; seek: (time: number) => Promise; onEvent: (callback: (event: any, data: any) => void) => void; - } + }; + plugin: { + call: (pluginId: string, method: string, args: any[]) => Promise; + }; } declare global { diff --git a/src/renderer/types/song.ts b/src/renderer/types/song.ts new file mode 100644 index 0000000..b54d10e --- /dev/null +++ b/src/renderer/types/song.ts @@ -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; +} diff --git a/src/renderer/views/Home.vue b/src/renderer/views/Home.vue new file mode 100644 index 0000000..60fb783 --- /dev/null +++ b/src/renderer/views/Home.vue @@ -0,0 +1,423 @@ + + + + + diff --git a/src/renderer/views/LocalMusic.vue b/src/renderer/views/LocalMusic.vue new file mode 100644 index 0000000..3f65038 --- /dev/null +++ b/src/renderer/views/LocalMusic.vue @@ -0,0 +1,54 @@ + + + + + diff --git a/src/renderer/views/Playlist.vue b/src/renderer/views/Playlist.vue new file mode 100644 index 0000000..e9b60c7 --- /dev/null +++ b/src/renderer/views/Playlist.vue @@ -0,0 +1,465 @@ + + + + +