1. 实现linux下的mpris和gnome状态栏歌词功能

This commit is contained in:
alger
2026-03-29 13:30:36 +08:00
committed by stark81
parent 8e3e4e610c
commit 33fc4f768c
5 changed files with 354 additions and 0 deletions

View File

@@ -36,6 +36,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 +50,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",
@@ -221,5 +223,9 @@
"electron",
"esbuild"
]
},
"optionalDependencies": {
"jsbi": "^4.3.2",
"x11": "^2.3.0"
}
}

View File

@@ -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);
});
// 所有窗口关闭时的处理

269
src/main/modules/mpris.ts Normal file
View File

@@ -0,0 +1,269 @@
import { app, BrowserWindow, ipcMain } from 'electron';
import Player from 'mpris-service';
const dbus = require('@httptoolkit/dbus-native');
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;
export function initializeMpris(mainWindowRef: BrowserWindow) {
if (process.platform !== 'linux') return;
if (mprisPlayer) {
console.log('[MPRIS] Already initialized, skipping');
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('global-shortcut', 'togglePlay');
}
});
mprisPlayer.on('play', () => {
if (mainWindow) {
mainWindow.webContents.send('global-shortcut', 'togglePlay');
}
});
mprisPlayer.on('playpause', () => {
if (mainWindow) {
mainWindow.webContents.send('global-shortcut', 'togglePlay');
}
});
mprisPlayer.on('stop', () => {
if (mainWindow) {
mainWindow.webContents.send('global-shortcut', 'togglePlay');
}
});
mprisPlayer.getPosition = (): number => {
console.log('[MPRIS] getPosition called, returning:', currentPosition);
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);
}
});
ipcMain.on('mpris-position-update', (_, position: number) => {
currentPosition = position * 1000 * 1000;
mprisPlayer.seeked(position * 1000 * 1000);
mprisPlayer.getPosition = () => position * 1000 * 1000;
mprisPlayer.position = position * 1000 * 1000;
});
ipcMain.on('tray-lyric-update', async (_, lrcObj: string) => {
sendTrayLyric(lrcObj);
});
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 (mprisPlayer) {
mprisPlayer.quit();
mprisPlayer = null;
}
}
function initTrayLyric() {
if (process.platform !== 'linux') {
console.log('[TrayLyric] Not Linux, skipping');
return;
}
console.log('[TrayLyric] Initializing...');
const serviceName = 'org.gnome.Shell.TrayLyric';
try {
const sessionBus = dbus.sessionBus({});
trayLyricBus = sessionBus;
console.log('[TrayLyric] Session bus created, type:');
// 使用 invoke 方法调用 D-Bus 方法
const dbusPath = '/org/freedesktop/DBus';
const dbusInterface = 'org.freedesktop.DBus';
// 先尝试直接获取接口并使用 signals
sessionBus.invoke(
{
path: dbusPath,
interface: dbusInterface,
member: 'GetNameOwner',
destination: 'org.freedesktop.DBus',
signature: 's',
body: [serviceName]
},
(err: any, result: any) => {
console.log('[TrayLyric] GetNameOwner result:', err, result);
if (err || !result) {
console.log('[TrayLyric] Service not running yet');
} else {
console.log('[TrayLyric] Service is running, owner:', result[0]);
onServiceAvailable();
}
}
);
} catch (err) {
console.error('[TrayLyric] Exception during init:', err);
}
function onServiceAvailable() {
console.log('[TrayLyric] onServiceAvailable called');
if (!trayLyricBus) {
console.log('[TrayLyric] Bus not available');
return;
}
console.log('[TrayLyric] Getting service interface...');
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) {
console.log('[TrayLyric] Interface or bus not ready, skipping');
return;
}
// 使用 invoke 方法调用 D-Bus 方法
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);
}
}
);
}

View File

@@ -52,6 +52,8 @@ export const textColors = ref<any>(getTextColors());
export let playMusic: ComputedRef<SongResult>;
export let artistList: ComputedRef<Artist[]>;
let lastIndex = -1;
export const musicDB = await useIndexedDB(
'musicDB',
[
@@ -329,6 +331,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 +380,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 +437,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) {
@@ -807,6 +829,37 @@ export const sendLyricToWin = () => {
}
};
// 发送歌词到系统托盘歌词TrayLyric
const sendTrayLyric = (index: number) => {
const platformValue = window.electron.ipcRenderer.sendSync('get-platform');
console.log(
'[TrayLyric] sendTrayLyric called, isElectron:',
isElectron,
'platform:',
platformValue
);
if (!isElectron || platformValue !== '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;

View File

@@ -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,18 @@ const onUpdateAppShortcuts = (_event: unknown, shortcuts: unknown) => {
updateAppShortcuts(shortcuts);
};
const onMprisSeek = (_event: unknown, position: number) => {
if (audioService) {
audioService.seek(position);
}
};
const onMprisSetPosition = (_event: unknown, position: number) => {
if (audioService) {
audioService.seek(position);
}
};
function shouldSkipAction(action: ShortcutAction): boolean {
const now = Date.now();
const lastTimestamp = actionTimestamps.get(action) ?? 0;
@@ -192,6 +205,8 @@ export function initAppShortcuts() {
window.electron.ipcRenderer.on('global-shortcut', onGlobalShortcut);
window.electron.ipcRenderer.on('update-app-shortcuts', onUpdateAppShortcuts);
window.electron.ipcRenderer.on('mpris-seek', onMprisSeek);
window.electron.ipcRenderer.on('mpris-set-position', onMprisSetPosition);
const storedShortcuts = window.electron.ipcRenderer.sendSync('get-store-value', 'shortcuts');
updateAppShortcuts(storedShortcuts);