mirror of
https://github.com/algerkong/AlgerMusicPlayer.git
synced 2026-05-06 10:17:23 +08:00
Compare commits
11 Commits
8e3e4e610c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 97220761cf | |||
| 7282e876f4 | |||
| 6b22713854 | |||
| 0d960aa8d5 | |||
| e066efb373 | |||
| b0b3eb3326 | |||
| 4a50886a68 | |||
| f9222b699d | |||
| 030a1f1c85 | |||
| 3f31278131 | |||
| 33fc4f768c |
@@ -0,0 +1,71 @@
|
||||
name: PR Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
|
||||
jobs:
|
||||
# 检查 PR 标题是否符合 Conventional Commits 规范
|
||||
pr-title:
|
||||
name: PR Title
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Install commitlint
|
||||
run: npm install --no-save @commitlint/cli @commitlint/config-conventional
|
||||
|
||||
- name: Validate PR title
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
run: echo "$PR_TITLE" | npx commitlint
|
||||
|
||||
# 检查所有提交信息是否符合 Conventional Commits 规范
|
||||
commit-messages:
|
||||
name: Commit Messages
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Install commitlint
|
||||
run: npm install --no-save @commitlint/cli @commitlint/config-conventional
|
||||
|
||||
- name: Validate commit messages
|
||||
run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
|
||||
|
||||
# 运行 lint 和类型检查
|
||||
code-quality:
|
||||
name: Code Quality
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
|
||||
- name: Lint
|
||||
run: npx eslint --max-warnings 0 "src/**/*.{ts,tsx,vue,js}"
|
||||
|
||||
- name: Type check
|
||||
run: npm run typecheck
|
||||
|
||||
- name: I18n check
|
||||
run: npm run lint:i18n
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
npx --no -- commitlint --edit "$1"
|
||||
@@ -0,0 +1,12 @@
|
||||
module.exports = {
|
||||
extends: ['@commitlint/config-conventional'],
|
||||
rules: {
|
||||
'type-enum': [
|
||||
2,
|
||||
'always',
|
||||
['feat', 'fix', 'perf', 'refactor', 'docs', 'style', 'test', 'build', 'ci', 'chore', 'revert']
|
||||
],
|
||||
'subject-empty': [2, 'never'],
|
||||
'type-empty': [2, 'never']
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 修复 Linux 下 Electron sandbox 权限问题
|
||||
* chrome-sandbox 需要 root 拥有且权限为 4755
|
||||
*
|
||||
* 注意:此脚本需要 sudo 权限,仅在 CI 环境或手动执行时使用
|
||||
* 用法:sudo node fix-sandbox.js
|
||||
*/
|
||||
const { execSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
const sandboxPath = path.resolve('./node_modules/electron/dist/chrome-sandbox');
|
||||
if (fs.existsSync(sandboxPath)) {
|
||||
execSync(`sudo chown root:root ${sandboxPath}`);
|
||||
execSync(`sudo chmod 4755 ${sandboxPath}`);
|
||||
console.log('[fix-sandbox] chrome-sandbox permissions fixed');
|
||||
} else {
|
||||
console.log('[fix-sandbox] chrome-sandbox not found, skipping');
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
"dev:web": "vite dev",
|
||||
"build": "electron-vite build",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"fix-sandbox": "node fix-sandbox.js",
|
||||
"build:unpack": "npm run build && electron-builder --dir",
|
||||
"build:win": "npm run build && electron-builder --win --publish never",
|
||||
"build:mac": "npm run build && electron-builder --mac --x64 --publish never && cp dist/latest-mac.yml dist/latest-mac-x64.yml && electron-builder --mac --arm64 --publish never && cp dist/latest-mac.yml dist/latest-mac-arm64.yml && node scripts/merge_latest_mac_yml.mjs dist/latest-mac-x64.yml dist/latest-mac-arm64.yml dist/latest-mac.yml",
|
||||
@@ -36,6 +37,7 @@
|
||||
"dependencies": {
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@httptoolkit/dbus-native": "^0.1.5",
|
||||
"@unblockneteasemusic/server": "^0.27.10",
|
||||
"cors": "^2.8.5",
|
||||
"crypto-js": "^4.2.0",
|
||||
@@ -49,6 +51,7 @@
|
||||
"form-data": "^4.0.5",
|
||||
"husky": "^9.1.7",
|
||||
"jsencrypt": "^3.5.4",
|
||||
"mpris-service": "^2.1.2",
|
||||
"music-metadata": "^11.10.3",
|
||||
"netease-cloud-music-api-alger": "^4.30.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
@@ -58,6 +61,8 @@
|
||||
"vue-i18n": "^11.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^20.5.0",
|
||||
"@commitlint/config-conventional": "^20.5.0",
|
||||
"@electron-toolkit/eslint-config": "^2.1.0",
|
||||
"@electron-toolkit/eslint-config-ts": "^3.1.0",
|
||||
"@electron-toolkit/tsconfig": "^1.0.1",
|
||||
@@ -221,5 +226,9 @@
|
||||
"electron",
|
||||
"esbuild"
|
||||
]
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"jsbi": "^4.3.2",
|
||||
"x11": "^2.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { initializeFonts } from './modules/fonts';
|
||||
import { initializeLocalMusicScanner } from './modules/localMusicScanner';
|
||||
import { initializeLoginWindow } from './modules/loginWindow';
|
||||
import { initLxMusicHttp } from './modules/lxMusicHttp';
|
||||
import { initializeMpris, updateMprisCurrentSong, updateMprisPlayState } from './modules/mpris';
|
||||
import { initializeOtherApi } from './modules/otherApi';
|
||||
import { initializeRemoteControl } from './modules/remoteControl';
|
||||
import { initializeShortcuts } from './modules/shortcuts';
|
||||
@@ -82,6 +83,9 @@ function initialize(configStore: any) {
|
||||
// 初始化远程控制服务
|
||||
initializeRemoteControl(mainWindow);
|
||||
|
||||
// 初始化 MPRIS 服务 (Linux)
|
||||
initializeMpris(mainWindow);
|
||||
|
||||
// 初始化更新处理程序
|
||||
setupUpdateHandlers(mainWindow);
|
||||
}
|
||||
@@ -92,6 +96,11 @@ const isSingleInstance = app.requestSingleInstanceLock();
|
||||
if (!isSingleInstance) {
|
||||
app.quit();
|
||||
} else {
|
||||
// 禁用 Chromium 内置的 MediaSession MPRIS 服务,避免重复显示
|
||||
if (process.platform === 'linux') {
|
||||
app.commandLine.appendSwitch('disable-features', 'MediaSessionService');
|
||||
}
|
||||
|
||||
// 在应用准备就绪前初始化GPU加速设置
|
||||
// 必须在 app.ready 之前调用 disableHardwareAcceleration
|
||||
try {
|
||||
@@ -171,11 +180,13 @@ if (!isSingleInstance) {
|
||||
// 监听播放状态变化
|
||||
ipcMain.on('update-play-state', (_, playing: boolean) => {
|
||||
updatePlayState(playing);
|
||||
updateMprisPlayState(playing);
|
||||
});
|
||||
|
||||
// 监听当前歌曲变化
|
||||
ipcMain.on('update-current-song', (_, song: any) => {
|
||||
updateCurrentSong(song);
|
||||
updateMprisCurrentSong(song);
|
||||
});
|
||||
|
||||
// 所有窗口关闭时的处理
|
||||
|
||||
@@ -10,6 +10,65 @@ let isDragging = false;
|
||||
|
||||
// 添加窗口大小变化防护
|
||||
let originalSize = { width: 0, height: 0 };
|
||||
// 鼠标位置轮询仅在"锁定 + 可见"时启用,解锁态下 DOM 事件已足够
|
||||
let mousePresenceTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let lastMouseInside: boolean | null = null;
|
||||
let isLyricLocked = false;
|
||||
let isLyricWindowVisible = false;
|
||||
|
||||
const isPointInsideWindow = (
|
||||
point: { x: number; y: number },
|
||||
bounds: { x: number; y: number; width: number; height: number }
|
||||
) => {
|
||||
return (
|
||||
point.x >= bounds.x &&
|
||||
point.x < bounds.x + bounds.width &&
|
||||
point.y >= bounds.y &&
|
||||
point.y < bounds.y + bounds.height
|
||||
);
|
||||
};
|
||||
|
||||
const stopMousePresenceTracking = () => {
|
||||
if (mousePresenceTimer) {
|
||||
clearInterval(mousePresenceTimer);
|
||||
mousePresenceTimer = null;
|
||||
}
|
||||
lastMouseInside = null;
|
||||
};
|
||||
|
||||
const emitMousePresence = () => {
|
||||
if (!lyricWindow || lyricWindow.isDestroyed()) return;
|
||||
|
||||
const mousePoint = screen.getCursorScreenPoint();
|
||||
const bounds = lyricWindow.getBounds();
|
||||
const isInside = isPointInsideWindow(mousePoint, bounds);
|
||||
|
||||
if (isInside === lastMouseInside) return;
|
||||
|
||||
lastMouseInside = isInside;
|
||||
lyricWindow.webContents.send('lyric-mouse-presence', isInside);
|
||||
};
|
||||
|
||||
const startMousePresenceTracking = () => {
|
||||
if (mousePresenceTimer) return;
|
||||
|
||||
emitMousePresence();
|
||||
mousePresenceTimer = setInterval(() => {
|
||||
if (!lyricWindow || lyricWindow.isDestroyed()) {
|
||||
stopMousePresenceTracking();
|
||||
return;
|
||||
}
|
||||
emitMousePresence();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const syncMousePresenceTracking = () => {
|
||||
if (isLyricLocked && isLyricWindowVisible && lyricWindow && !lyricWindow.isDestroyed()) {
|
||||
startMousePresenceTracking();
|
||||
} else {
|
||||
stopMousePresenceTracking();
|
||||
}
|
||||
};
|
||||
|
||||
const createWin = () => {
|
||||
console.log('Creating lyric window');
|
||||
@@ -102,12 +161,32 @@ const createWin = () => {
|
||||
|
||||
// 监听窗口关闭事件
|
||||
lyricWindow.on('closed', () => {
|
||||
stopMousePresenceTracking();
|
||||
isLyricLocked = false;
|
||||
isLyricWindowVisible = false;
|
||||
if (lyricWindow) {
|
||||
lyricWindow.destroy();
|
||||
lyricWindow = null;
|
||||
}
|
||||
});
|
||||
|
||||
lyricWindow.on('show', () => {
|
||||
isLyricWindowVisible = true;
|
||||
syncMousePresenceTracking();
|
||||
});
|
||||
lyricWindow.on('hide', () => {
|
||||
isLyricWindowVisible = false;
|
||||
stopMousePresenceTracking();
|
||||
});
|
||||
lyricWindow.on('minimize', () => {
|
||||
isLyricWindowVisible = false;
|
||||
stopMousePresenceTracking();
|
||||
});
|
||||
lyricWindow.on('restore', () => {
|
||||
isLyricWindowVisible = true;
|
||||
syncMousePresenceTracking();
|
||||
});
|
||||
|
||||
// 监听窗口大小变化事件,保存新的尺寸
|
||||
lyricWindow.on('resize', () => {
|
||||
// 如果正在拖动,忽略大小调整事件
|
||||
@@ -205,6 +284,17 @@ export const loadLyricWindow = (ipcMain: IpcMain, mainWin: BrowserWindow): void
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.on('set-lyric-lock-state', (_, isLocked: boolean) => {
|
||||
isLyricLocked = isLocked;
|
||||
if (lyricWindow && !lyricWindow.isDestroyed()) {
|
||||
// 锁定时禁用 resize,避免鼠标移到边缘仍显示调整光标
|
||||
lyricWindow.setResizable(!isLocked);
|
||||
// 设置初始穿透状态,后续 polling 会按实际位置纠正
|
||||
lyricWindow.setIgnoreMouseEvents(isLocked, { forward: true });
|
||||
}
|
||||
syncMousePresenceTracking();
|
||||
});
|
||||
|
||||
// 处理鼠标事件
|
||||
ipcMain.on('mouseenter-lyric', () => {
|
||||
if (lyricWindow && !lyricWindow.isDestroyed()) {
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { app, BrowserWindow, ipcMain } from 'electron';
|
||||
import Player from 'mpris-service';
|
||||
|
||||
let dbusModule: any;
|
||||
try {
|
||||
dbusModule = require('@httptoolkit/dbus-native');
|
||||
} catch {
|
||||
// dbus-native 不可用(非 Linux 环境)
|
||||
}
|
||||
|
||||
interface SongInfo {
|
||||
id?: number | string;
|
||||
name: string;
|
||||
picUrl?: string;
|
||||
ar?: Array<{ name: string }>;
|
||||
artists?: Array<{ name: string }>;
|
||||
al?: { name: string };
|
||||
album?: { name: string };
|
||||
duration?: number;
|
||||
dt?: number;
|
||||
song?: {
|
||||
artists?: Array<{ name: string }>;
|
||||
album?: { name: string };
|
||||
duration?: number;
|
||||
picUrl?: string;
|
||||
};
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
let mprisPlayer: Player | null = null;
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let currentPosition = 0;
|
||||
let trayLyricIface: any = null;
|
||||
let trayLyricBus: any = null;
|
||||
|
||||
// 保存 IPC 处理函数引用,用于清理
|
||||
let onPositionUpdate: ((event: any, position: number) => void) | null = null;
|
||||
let onTrayLyricUpdate: ((event: any, lrcObj: string) => void) | null = null;
|
||||
|
||||
export function initializeMpris(mainWindowRef: BrowserWindow) {
|
||||
if (process.platform !== 'linux') return;
|
||||
|
||||
if (mprisPlayer) {
|
||||
return;
|
||||
}
|
||||
|
||||
mainWindow = mainWindowRef;
|
||||
|
||||
try {
|
||||
mprisPlayer = Player({
|
||||
name: 'AlgerMusicPlayer',
|
||||
identity: 'Alger Music Player',
|
||||
supportedUriSchemes: ['file', 'http', 'https'],
|
||||
supportedMimeTypes: [
|
||||
'audio/mpeg',
|
||||
'audio/mp3',
|
||||
'audio/flac',
|
||||
'audio/wav',
|
||||
'audio/ogg',
|
||||
'audio/aac',
|
||||
'audio/m4a'
|
||||
],
|
||||
supportedInterfaces: ['player']
|
||||
});
|
||||
|
||||
mprisPlayer.on('quit', () => {
|
||||
app.quit();
|
||||
});
|
||||
|
||||
mprisPlayer.on('raise', () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
});
|
||||
|
||||
mprisPlayer.on('next', () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('global-shortcut', 'nextPlay');
|
||||
}
|
||||
});
|
||||
|
||||
mprisPlayer.on('previous', () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('global-shortcut', 'prevPlay');
|
||||
}
|
||||
});
|
||||
|
||||
mprisPlayer.on('pause', () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('mpris-pause');
|
||||
}
|
||||
});
|
||||
|
||||
mprisPlayer.on('play', () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('mpris-play');
|
||||
}
|
||||
});
|
||||
|
||||
mprisPlayer.on('playpause', () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('global-shortcut', 'togglePlay');
|
||||
}
|
||||
});
|
||||
|
||||
mprisPlayer.on('stop', () => {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('mpris-pause');
|
||||
}
|
||||
});
|
||||
|
||||
mprisPlayer.getPosition = (): number => {
|
||||
return currentPosition;
|
||||
};
|
||||
|
||||
mprisPlayer.on('seek', (offset: number) => {
|
||||
if (mainWindow) {
|
||||
const newPosition = Math.max(0, currentPosition + offset / 1000000);
|
||||
mainWindow.webContents.send('mpris-seek', newPosition);
|
||||
}
|
||||
});
|
||||
|
||||
mprisPlayer.on('position', (event: { trackId: string; position: number }) => {
|
||||
if (mainWindow) {
|
||||
mainWindow.webContents.send('mpris-set-position', event.position / 1000000);
|
||||
}
|
||||
});
|
||||
|
||||
onPositionUpdate = (_, position: number) => {
|
||||
currentPosition = position * 1000 * 1000;
|
||||
if (mprisPlayer) {
|
||||
mprisPlayer.seeked(position * 1000 * 1000);
|
||||
mprisPlayer.getPosition = () => position * 1000 * 1000;
|
||||
mprisPlayer.position = position * 1000 * 1000;
|
||||
}
|
||||
};
|
||||
ipcMain.on('mpris-position-update', onPositionUpdate);
|
||||
|
||||
onTrayLyricUpdate = (_, lrcObj: string) => {
|
||||
sendTrayLyric(lrcObj);
|
||||
};
|
||||
ipcMain.on('tray-lyric-update', onTrayLyricUpdate);
|
||||
|
||||
initTrayLyric();
|
||||
|
||||
console.log('[MPRIS] Service initialized');
|
||||
} catch (error) {
|
||||
console.error('[MPRIS] Failed to initialize:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function updateMprisPlayState(playing: boolean) {
|
||||
if (!mprisPlayer || process.platform !== 'linux') return;
|
||||
mprisPlayer.playbackStatus = playing ? 'Playing' : 'Paused';
|
||||
}
|
||||
|
||||
export function updateMprisCurrentSong(song: SongInfo | null) {
|
||||
if (!mprisPlayer || process.platform !== 'linux') return;
|
||||
|
||||
if (!song) {
|
||||
mprisPlayer.metadata = {};
|
||||
mprisPlayer.playbackStatus = 'Stopped';
|
||||
return;
|
||||
}
|
||||
|
||||
const artists =
|
||||
song.ar?.map((a) => a.name).join(', ') ||
|
||||
song.artists?.map((a) => a.name).join(', ') ||
|
||||
song.song?.artists?.map((a) => a.name).join(', ') ||
|
||||
'';
|
||||
const album = song.al?.name || song.album?.name || song.song?.album?.name || '';
|
||||
const duration = song.duration || song.dt || song.song?.duration || 0;
|
||||
|
||||
mprisPlayer.metadata = {
|
||||
'mpris:trackid': mprisPlayer.objectPath(`track/${song.id || 0}`),
|
||||
'mpris:length': duration * 1000,
|
||||
'mpris:artUrl': song.picUrl || '',
|
||||
'xesam:title': song.name || '',
|
||||
'xesam:album': album,
|
||||
'xesam:artist': artists ? [artists] : []
|
||||
};
|
||||
}
|
||||
|
||||
export function updateMprisPosition(position: number) {
|
||||
if (!mprisPlayer || process.platform !== 'linux') return;
|
||||
mprisPlayer.seeked(position * 1000000);
|
||||
}
|
||||
|
||||
export function destroyMpris() {
|
||||
if (onPositionUpdate) {
|
||||
ipcMain.removeListener('mpris-position-update', onPositionUpdate);
|
||||
onPositionUpdate = null;
|
||||
}
|
||||
if (onTrayLyricUpdate) {
|
||||
ipcMain.removeListener('tray-lyric-update', onTrayLyricUpdate);
|
||||
onTrayLyricUpdate = null;
|
||||
}
|
||||
if (mprisPlayer) {
|
||||
mprisPlayer.quit();
|
||||
mprisPlayer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function initTrayLyric() {
|
||||
if (process.platform !== 'linux' || !dbusModule) return;
|
||||
|
||||
const serviceName = 'org.gnome.Shell.TrayLyric';
|
||||
|
||||
try {
|
||||
const sessionBus = dbusModule.sessionBus({});
|
||||
trayLyricBus = sessionBus;
|
||||
|
||||
const dbusPath = '/org/freedesktop/DBus';
|
||||
const dbusInterface = 'org.freedesktop.DBus';
|
||||
|
||||
sessionBus.invoke(
|
||||
{
|
||||
path: dbusPath,
|
||||
interface: dbusInterface,
|
||||
member: 'GetNameOwner',
|
||||
destination: 'org.freedesktop.DBus',
|
||||
signature: 's',
|
||||
body: [serviceName]
|
||||
},
|
||||
(err: any, result: any) => {
|
||||
if (err || !result) {
|
||||
console.log('[TrayLyric] Service not running');
|
||||
} else {
|
||||
onServiceAvailable();
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('[TrayLyric] Failed to init:', err);
|
||||
}
|
||||
|
||||
function onServiceAvailable() {
|
||||
if (!trayLyricBus) return;
|
||||
const path = '/' + serviceName.replace(/\./g, '/');
|
||||
trayLyricBus.getService(serviceName).getInterface(path, serviceName, (err: any, iface: any) => {
|
||||
if (err) {
|
||||
console.error('[TrayLyric] Failed to get service interface:', err);
|
||||
return;
|
||||
}
|
||||
trayLyricIface = iface;
|
||||
console.log('[TrayLyric] Service interface ready');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function sendTrayLyric(lrcObj: string) {
|
||||
if (!trayLyricIface || !trayLyricBus) return;
|
||||
|
||||
trayLyricBus.invoke(
|
||||
{
|
||||
path: '/org/gnome/Shell/TrayLyric',
|
||||
interface: 'org.gnome.Shell.TrayLyric',
|
||||
member: 'UpdateLyric',
|
||||
destination: 'org.gnome.Shell.TrayLyric',
|
||||
signature: 's',
|
||||
body: [lrcObj]
|
||||
},
|
||||
(err: any, _result: any) => {
|
||||
if (err) {
|
||||
console.error('[TrayLyric] Failed to invoke UpdateLyric:', err);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
Vendored
+23
@@ -0,0 +1,23 @@
|
||||
declare module 'mpris-service' {
|
||||
interface PlayerOptions {
|
||||
name: string;
|
||||
identity: string;
|
||||
supportedUriSchemes?: string[];
|
||||
supportedMimeTypes?: string[];
|
||||
supportedInterfaces?: string[];
|
||||
}
|
||||
|
||||
interface Player {
|
||||
on(event: string, callback: (...args: any[]) => void): void;
|
||||
playbackStatus: string;
|
||||
metadata: Record<string, any>;
|
||||
position: number;
|
||||
getPosition: () => number;
|
||||
seeked(position: number): void;
|
||||
objectPath(path: string): string;
|
||||
quit(): void;
|
||||
}
|
||||
|
||||
function Player(options: PlayerOptions): Player;
|
||||
export = Player;
|
||||
}
|
||||
+176
-126
@@ -52,6 +52,11 @@ export const textColors = ref<any>(getTextColors());
|
||||
export let playMusic: ComputedRef<SongResult>;
|
||||
export let artistList: ComputedRef<Artist[]>;
|
||||
|
||||
let lastIndex = -1;
|
||||
|
||||
// 缓存平台信息,避免每次歌词变化时同步 IPC 调用
|
||||
const cachedPlatform = isElectron ? window.electron.ipcRenderer.sendSync('get-platform') : 'web';
|
||||
|
||||
export const musicDB = await useIndexedDB(
|
||||
'musicDB',
|
||||
[
|
||||
@@ -144,124 +149,125 @@ const parseLyricsString = async (
|
||||
}
|
||||
};
|
||||
|
||||
// 设置音乐相关的监听器
|
||||
// 解析当前 playMusic.lyric 写入 lrcArray, 供 watcher / openLyric / onLyricWindowReady 共用
|
||||
const ensureLyricsLoaded = async (force = false) => {
|
||||
const songId = playMusic.value?.id;
|
||||
if (!songId) {
|
||||
lrcArray.value = [];
|
||||
lrcTimeArray.value = [];
|
||||
nowIndex.value = 0;
|
||||
return;
|
||||
}
|
||||
if (!force && lrcArray.value.length > 0) return;
|
||||
|
||||
await nextTick();
|
||||
|
||||
const lyricData = playMusic.value.lyric;
|
||||
if (lyricData && typeof lyricData === 'string') {
|
||||
const {
|
||||
lrcArray: parsedLrcArray,
|
||||
lrcTimeArray: parsedTimeArray,
|
||||
hasWordByWord
|
||||
} = await parseLyricsString(lyricData);
|
||||
lrcArray.value = parsedLrcArray;
|
||||
lrcTimeArray.value = parsedTimeArray;
|
||||
|
||||
if (playMusic.value.lyric && typeof playMusic.value.lyric === 'object') {
|
||||
playMusic.value.lyric.hasWordByWord = hasWordByWord;
|
||||
}
|
||||
} else if (lyricData && typeof lyricData === 'object' && lyricData.lrcArray?.length > 0) {
|
||||
const rawLrc = lyricData.lrcArray || [];
|
||||
lrcTimeArray.value = lyricData.lrcTimeArray || [];
|
||||
|
||||
try {
|
||||
const { translateLyrics } = await import('@/services/lyricTranslation');
|
||||
lrcArray.value = await translateLyrics(rawLrc as any);
|
||||
} catch (e) {
|
||||
console.error('翻译歌词失败,使用原始歌词:', e);
|
||||
lrcArray.value = rawLrc as any;
|
||||
}
|
||||
} else if (isElectron && playMusic.value.playMusicUrl?.startsWith('local:///')) {
|
||||
try {
|
||||
let filePath = decodeURIComponent(playMusic.value.playMusicUrl.replace('local:///', ''));
|
||||
// 处理 Windows 路径:/C:/... → C:/...
|
||||
if (/^\/[a-zA-Z]:\//.test(filePath)) {
|
||||
filePath = filePath.slice(1);
|
||||
}
|
||||
const embeddedLyrics = await window.api.getEmbeddedLyrics(filePath);
|
||||
if (embeddedLyrics) {
|
||||
const {
|
||||
lrcArray: parsedLrcArray,
|
||||
lrcTimeArray: parsedTimeArray,
|
||||
hasWordByWord
|
||||
} = await parseLyricsString(embeddedLyrics);
|
||||
lrcArray.value = parsedLrcArray;
|
||||
lrcTimeArray.value = parsedTimeArray;
|
||||
if (playMusic.value.lyric && typeof playMusic.value.lyric === 'object') {
|
||||
(playMusic.value.lyric as any).hasWordByWord = hasWordByWord;
|
||||
}
|
||||
} else if (typeof songId === 'number') {
|
||||
try {
|
||||
const { getMusicLrc } = await import('@/api/music');
|
||||
const res = await getMusicLrc(songId);
|
||||
if (res?.data?.lrc?.lyric) {
|
||||
const { lrcArray: apiLrcArray, lrcTimeArray: apiTimeArray } = await parseLyricsString(
|
||||
res.data.lrc.lyric
|
||||
);
|
||||
lrcArray.value = apiLrcArray;
|
||||
lrcTimeArray.value = apiTimeArray;
|
||||
}
|
||||
} catch (apiErr) {
|
||||
console.error('API lyrics fallback failed:', apiErr);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to extract embedded lyrics:', err);
|
||||
}
|
||||
} else if (typeof songId === 'number') {
|
||||
// 在线歌曲但 lyric 字段尚未加载, 主动调 API 兜底
|
||||
try {
|
||||
const { getMusicLrc } = await import('@/api/music');
|
||||
const res = await getMusicLrc(songId);
|
||||
if (res?.data?.lrc?.lyric) {
|
||||
const { lrcArray: apiLrcArray, lrcTimeArray: apiTimeArray } = await parseLyricsString(
|
||||
res.data.lrc.lyric
|
||||
);
|
||||
lrcArray.value = apiLrcArray;
|
||||
lrcTimeArray.value = apiTimeArray;
|
||||
}
|
||||
} catch (apiErr) {
|
||||
console.error('API lyrics fallback failed:', apiErr);
|
||||
}
|
||||
}
|
||||
|
||||
if (isElectron && isLyricWindowOpen.value) {
|
||||
sendLyricToWin();
|
||||
setTimeout(() => sendLyricToWin(), 500);
|
||||
}
|
||||
};
|
||||
|
||||
const setupMusicWatchers = () => {
|
||||
const store = getPlayerStore();
|
||||
|
||||
// 监听 playerStore.playMusic 的变化以更新歌词数据
|
||||
// 切歌时 id 变化, 强制重新解析
|
||||
watch(
|
||||
() => store.playMusic.id,
|
||||
async (newId, oldId) => {
|
||||
// 如果没有歌曲ID,清空歌词
|
||||
if (!newId) {
|
||||
lrcArray.value = [];
|
||||
lrcTimeArray.value = [];
|
||||
nowIndex.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// 避免相同ID的重复执行(但允许初始化时执行)
|
||||
if (newId === oldId && lrcArray.value.length > 0) return;
|
||||
|
||||
// 歌曲切换时重置歌词索引
|
||||
if (newId !== oldId) {
|
||||
nowIndex.value = 0;
|
||||
}
|
||||
|
||||
await nextTick(async () => {
|
||||
console.log('歌曲切换,更新歌词数据');
|
||||
|
||||
// 检查是否有原始歌词字符串需要解析
|
||||
const lyricData = playMusic.value.lyric;
|
||||
if (lyricData && typeof lyricData === 'string') {
|
||||
// 如果歌词是字符串格式,使用新的解析器
|
||||
const {
|
||||
lrcArray: parsedLrcArray,
|
||||
lrcTimeArray: parsedTimeArray,
|
||||
hasWordByWord
|
||||
} = await parseLyricsString(lyricData);
|
||||
lrcArray.value = parsedLrcArray;
|
||||
lrcTimeArray.value = parsedTimeArray;
|
||||
|
||||
// 更新歌曲的歌词数据结构
|
||||
if (playMusic.value.lyric && typeof playMusic.value.lyric === 'object') {
|
||||
playMusic.value.lyric.hasWordByWord = hasWordByWord;
|
||||
}
|
||||
} else if (lyricData && typeof lyricData === 'object' && lyricData.lrcArray?.length > 0) {
|
||||
// 使用现有的歌词数据结构
|
||||
const rawLrc = lyricData.lrcArray || [];
|
||||
lrcTimeArray.value = lyricData.lrcTimeArray || [];
|
||||
|
||||
try {
|
||||
const { translateLyrics } = await import('@/services/lyricTranslation');
|
||||
lrcArray.value = await translateLyrics(rawLrc as any);
|
||||
} catch (e) {
|
||||
console.error('翻译歌词失败,使用原始歌词:', e);
|
||||
lrcArray.value = rawLrc as any;
|
||||
}
|
||||
} else if (isElectron && playMusic.value.playMusicUrl?.startsWith('local:///')) {
|
||||
// 从下载/本地文件的 ID3/FLAC 元数据中提取嵌入歌词
|
||||
try {
|
||||
let filePath = decodeURIComponent(
|
||||
playMusic.value.playMusicUrl.replace('local:///', '')
|
||||
);
|
||||
// 处理 Windows 路径:/C:/... → C:/...
|
||||
if (/^\/[a-zA-Z]:\//.test(filePath)) {
|
||||
filePath = filePath.slice(1);
|
||||
}
|
||||
const embeddedLyrics = await window.api.getEmbeddedLyrics(filePath);
|
||||
if (embeddedLyrics) {
|
||||
const {
|
||||
lrcArray: parsedLrcArray,
|
||||
lrcTimeArray: parsedTimeArray,
|
||||
hasWordByWord
|
||||
} = await parseLyricsString(embeddedLyrics);
|
||||
lrcArray.value = parsedLrcArray;
|
||||
lrcTimeArray.value = parsedTimeArray;
|
||||
if (playMusic.value.lyric && typeof playMusic.value.lyric === 'object') {
|
||||
(playMusic.value.lyric as any).hasWordByWord = hasWordByWord;
|
||||
}
|
||||
} else {
|
||||
// 无嵌入歌词 — 若有数字 ID,尝试 API 兜底
|
||||
const songId = playMusic.value.id;
|
||||
if (songId && typeof songId === 'number') {
|
||||
try {
|
||||
const { getMusicLrc } = await import('@/api/music');
|
||||
const res = await getMusicLrc(songId);
|
||||
if (res?.data?.lrc?.lyric) {
|
||||
const { lrcArray: apiLrcArray, lrcTimeArray: apiTimeArray } =
|
||||
await parseLyricsString(res.data.lrc.lyric);
|
||||
lrcArray.value = apiLrcArray;
|
||||
lrcTimeArray.value = apiTimeArray;
|
||||
}
|
||||
} catch (apiErr) {
|
||||
console.error('API lyrics fallback failed:', apiErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to extract embedded lyrics:', err);
|
||||
}
|
||||
} else {
|
||||
// 无歌词数据
|
||||
lrcArray.value = [];
|
||||
lrcTimeArray.value = [];
|
||||
}
|
||||
// 当歌词数据更新时,如果歌词窗口打开,则发送数据
|
||||
if (isElectron && isLyricWindowOpen.value) {
|
||||
console.log('歌词窗口已打开,同步最新歌词数据');
|
||||
// 不管歌词数组是否为空,都发送最新数据
|
||||
sendLyricToWin();
|
||||
|
||||
// 再次延迟发送,确保歌词窗口已完全加载
|
||||
setTimeout(() => {
|
||||
sendLyricToWin();
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
if (newId !== oldId) nowIndex.value = 0;
|
||||
await ensureLyricsLoaded(true);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
// 同一首歌但 lyric 字段后到 (重启 + autoPlay 关闭场景)
|
||||
watch(
|
||||
() => playMusic.value?.lyric,
|
||||
() => {
|
||||
if (lrcArray.value.length === 0 && playMusic.value?.id) {
|
||||
ensureLyricsLoaded();
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const setupAudioListeners = () => {
|
||||
@@ -329,6 +335,12 @@ const setupAudioListeners = () => {
|
||||
sendLyricToWin();
|
||||
}
|
||||
}
|
||||
if (isElectron && lrcArray.value[nowIndex.value]) {
|
||||
if (lastIndex !== nowIndex.value) {
|
||||
sendTrayLyric(nowIndex.value);
|
||||
lastIndex = nowIndex.value;
|
||||
}
|
||||
}
|
||||
|
||||
// === 逐字歌词行内进度 ===
|
||||
const { start, end } = currentLrcTiming.value;
|
||||
@@ -372,6 +384,15 @@ const setupAudioListeners = () => {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// === MPRIS 进度更新(每 ~1 秒)===
|
||||
if (isElectron && lyricThrottleCounter % 20 === 0) {
|
||||
try {
|
||||
window.electron.ipcRenderer.send('mpris-position-update', currentTime);
|
||||
} catch {
|
||||
// 忽略发送失败
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('进度更新 interval 出错:', error);
|
||||
// 出错时不清除 interval,让下一次 tick 继续尝试
|
||||
@@ -420,6 +441,11 @@ const setupAudioListeners = () => {
|
||||
if (typeof currentTime === 'number' && !Number.isNaN(currentTime)) {
|
||||
nowTime.value = currentTime;
|
||||
|
||||
// === MPRIS seek 时同步进度 ===
|
||||
if (isElectron) {
|
||||
window.electron.ipcRenderer.send('mpris-position-update', currentTime);
|
||||
}
|
||||
|
||||
// 检查是否需要更新歌词
|
||||
const newIndex = getLrcIndex(nowTime.value);
|
||||
if (newIndex !== nowIndex.value) {
|
||||
@@ -461,7 +487,10 @@ const setupAudioListeners = () => {
|
||||
if (isElectron) {
|
||||
window.api.sendSong(cloneDeep(getPlayerStore().playMusic));
|
||||
}
|
||||
// 启动进度更新
|
||||
// 兜底: 重启后首次点播放时 lrcArray 仍为空则主动加载
|
||||
if (lrcArray.value.length === 0 && playMusic.value?.id) {
|
||||
ensureLyricsLoaded();
|
||||
}
|
||||
startProgressInterval();
|
||||
});
|
||||
|
||||
@@ -807,6 +836,30 @@ export const sendLyricToWin = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 发送歌词到系统托盘歌词(TrayLyric)
|
||||
const sendTrayLyric = (index: number) => {
|
||||
if (!isElectron || cachedPlatform !== 'linux') return;
|
||||
|
||||
try {
|
||||
const lyric = lrcArray.value[index];
|
||||
if (!lyric) return;
|
||||
|
||||
const currentTime = lrcTimeArray.value[index] || 0;
|
||||
const nextTime = lrcTimeArray.value[index + 1] || currentTime + 3;
|
||||
const duration = nextTime - currentTime;
|
||||
|
||||
const lrcObj = JSON.stringify({
|
||||
content: lyric.text || '',
|
||||
time: duration.toFixed(1),
|
||||
sender: 'AlgerMusicPlayer'
|
||||
});
|
||||
|
||||
window.electron.ipcRenderer.send('tray-lyric-update', lrcObj);
|
||||
} catch (error) {
|
||||
console.error('[TrayLyric] Failed to send:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 歌词同步定时器
|
||||
let lyricSyncInterval: any = null;
|
||||
|
||||
@@ -844,28 +897,20 @@ const stopLyricSync = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 修改openLyric函数,添加定时同步
|
||||
export const openLyric = () => {
|
||||
export const openLyric = async () => {
|
||||
if (!isElectron) return;
|
||||
|
||||
// 检查是否有播放中的歌曲
|
||||
if (!playMusic.value || !playMusic.value.id) {
|
||||
console.log('没有正在播放的歌曲,无法打开歌词窗口');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Opening lyric window with current song:', playMusic.value?.name);
|
||||
|
||||
isLyricWindowOpen.value = !isLyricWindowOpen.value;
|
||||
if (isLyricWindowOpen.value) {
|
||||
// 立即打开窗口
|
||||
window.api.openLyric();
|
||||
|
||||
// 确保有歌词数据,如果没有,则使用默认的"无歌词"提示
|
||||
// 先发"加载中"占位, 防止窗口启动期间显示"无歌词"
|
||||
if (!lrcArray.value || lrcArray.value.length === 0) {
|
||||
// 如果当前播放的歌曲有ID但没有歌词,则尝试加载歌词
|
||||
console.log('尝试加载歌词数据...');
|
||||
// 发送默认的"无歌词"数据
|
||||
const emptyLyricData = {
|
||||
type: 'empty',
|
||||
nowIndex: 0,
|
||||
@@ -879,12 +924,15 @@ export const openLyric = () => {
|
||||
playMusic: playMusic.value
|
||||
};
|
||||
window.api.sendLyric(JSON.stringify(emptyLyricData));
|
||||
|
||||
// 关键: 主动加载歌词, 不依赖 watcher
|
||||
// (重启场景下 playerCore.playMusic 整体替换可能未触发 lyric watcher)
|
||||
await ensureLyricsLoaded(true);
|
||||
} else {
|
||||
// 发送完整歌词数据
|
||||
sendLyricToWin();
|
||||
}
|
||||
|
||||
// 延迟重发一次,以防窗口加载略慢
|
||||
// 延迟重发, 防窗口加载慢丢消息
|
||||
setTimeout(() => {
|
||||
if (isLyricWindowOpen.value) {
|
||||
sendLyricToWin();
|
||||
@@ -1006,11 +1054,13 @@ export const initAudioListeners = async () => {
|
||||
window.api.onLyricWindowClosed(() => {
|
||||
isLyricWindowOpen.value = false;
|
||||
});
|
||||
// 歌词窗口 Vue 加载完成后,发送完整歌词数据
|
||||
window.api.onLyricWindowReady(() => {
|
||||
if (isLyricWindowOpen.value) {
|
||||
sendLyricToWin();
|
||||
window.api.onLyricWindowReady(async () => {
|
||||
if (!isLyricWindowOpen.value) return;
|
||||
// 窗口加载完成时再兜底加载一次, 防止 openLyric 阶段 lyric 字段尚未到位
|
||||
if (lrcArray.value.length === 0 && playMusic.value?.id) {
|
||||
await ensureLyricsLoaded(true);
|
||||
}
|
||||
sendLyricToWin();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { onMounted, onUnmounted } from 'vue';
|
||||
|
||||
import i18n from '@/../i18n/renderer';
|
||||
import { audioService } from '@/services/audioService';
|
||||
import { usePlayerStore, useSettingsStore } from '@/store';
|
||||
|
||||
import {
|
||||
@@ -37,6 +38,26 @@ const onUpdateAppShortcuts = (_event: unknown, shortcuts: unknown) => {
|
||||
updateAppShortcuts(shortcuts);
|
||||
};
|
||||
|
||||
const onMprisSeekOrSetPosition = (_event: unknown, position: number) => {
|
||||
if (audioService) {
|
||||
audioService.seek(position);
|
||||
}
|
||||
};
|
||||
|
||||
const onMprisPlay = async () => {
|
||||
const playerStore = usePlayerStore();
|
||||
if (!playerStore.play && playerStore.playMusic?.id) {
|
||||
await playerStore.setPlay({ ...playerStore.playMusic });
|
||||
}
|
||||
};
|
||||
|
||||
const onMprisPause = async () => {
|
||||
const playerStore = usePlayerStore();
|
||||
if (playerStore.play) {
|
||||
await playerStore.handlePause();
|
||||
}
|
||||
};
|
||||
|
||||
function shouldSkipAction(action: ShortcutAction): boolean {
|
||||
const now = Date.now();
|
||||
const lastTimestamp = actionTimestamps.get(action) ?? 0;
|
||||
@@ -192,6 +213,10 @@ export function initAppShortcuts() {
|
||||
|
||||
window.electron.ipcRenderer.on('global-shortcut', onGlobalShortcut);
|
||||
window.electron.ipcRenderer.on('update-app-shortcuts', onUpdateAppShortcuts);
|
||||
window.electron.ipcRenderer.on('mpris-seek', onMprisSeekOrSetPosition);
|
||||
window.electron.ipcRenderer.on('mpris-set-position', onMprisSeekOrSetPosition);
|
||||
window.electron.ipcRenderer.on('mpris-play', onMprisPlay);
|
||||
window.electron.ipcRenderer.on('mpris-pause', onMprisPause);
|
||||
|
||||
const storedShortcuts = window.electron.ipcRenderer.sendSync('get-store-value', 'shortcuts');
|
||||
updateAppShortcuts(storedShortcuts);
|
||||
@@ -211,6 +236,10 @@ export function cleanupAppShortcuts() {
|
||||
|
||||
window.electron.ipcRenderer.removeListener('global-shortcut', onGlobalShortcut);
|
||||
window.electron.ipcRenderer.removeListener('update-app-shortcuts', onUpdateAppShortcuts);
|
||||
window.electron.ipcRenderer.removeListener('mpris-seek', onMprisSeekOrSetPosition);
|
||||
window.electron.ipcRenderer.removeListener('mpris-set-position', onMprisSeekOrSetPosition);
|
||||
window.electron.ipcRenderer.removeListener('mpris-play', onMprisPlay);
|
||||
window.electron.ipcRenderer.removeListener('mpris-pause', onMprisPause);
|
||||
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
|
||||
@@ -341,6 +341,7 @@ const displayMode = computed(() => lyricSetting.value.displayMode);
|
||||
const showTranslation = computed(() => lyricSetting.value.showTranslation);
|
||||
|
||||
let hideControlsTimer: number | null = null;
|
||||
let removeMousePresenceListener: (() => void) | null = null;
|
||||
|
||||
const isHovering = ref(false);
|
||||
|
||||
@@ -400,6 +401,7 @@ watch(
|
||||
// 锁定时自动关闭主题色面板
|
||||
showThemeColorPanel.value = false;
|
||||
}
|
||||
windowData.electron.ipcRenderer.send('set-lyric-lock-state', newLock);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -782,10 +784,27 @@ onMounted(() => {
|
||||
|
||||
// 通知主窗口歌词窗口已就绪,请求发送完整歌词数据
|
||||
windowData.electron.ipcRenderer.send('lyric-ready');
|
||||
|
||||
removeMousePresenceListener = window.ipcRenderer.on(
|
||||
'lyric-mouse-presence',
|
||||
(isInside: boolean) => {
|
||||
isHovering.value = isInside;
|
||||
|
||||
if (lyricSetting.value.isLock) {
|
||||
windowData.electron.ipcRenderer.send('set-ignore-mouse', !isInside);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
windowData.electron.ipcRenderer.send('set-lyric-lock-state', lyricSetting.value.isLock);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', updateContainerHeight);
|
||||
if (removeMousePresenceListener) {
|
||||
removeMousePresenceListener();
|
||||
removeMousePresenceListener = null;
|
||||
}
|
||||
});
|
||||
|
||||
const checkTheme = () => {
|
||||
|
||||
Reference in New Issue
Block a user