diff --git a/package.json b/package.json index 8caa6e7..b873b8a 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/main/index.ts b/src/main/index.ts index b47e8e4..40190df 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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); }); // 所有窗口关闭时的处理 diff --git a/src/main/modules/mpris.ts b/src/main/modules/mpris.ts new file mode 100644 index 0000000..92a4903 --- /dev/null +++ b/src/main/modules/mpris.ts @@ -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); + } + } + ); +} diff --git a/src/renderer/hooks/MusicHook.ts b/src/renderer/hooks/MusicHook.ts index 145e25e..06d6d36 100644 --- a/src/renderer/hooks/MusicHook.ts +++ b/src/renderer/hooks/MusicHook.ts @@ -52,6 +52,8 @@ export const textColors = ref(getTextColors()); export let playMusic: ComputedRef; export let artistList: ComputedRef; +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; diff --git a/src/renderer/utils/appShortcuts.ts b/src/renderer/utils/appShortcuts.ts index 491a19f..a85cbeb 100644 --- a/src/renderer/utils/appShortcuts.ts +++ b/src/renderer/utils/appShortcuts.ts @@ -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);