diff --git a/fix-sandbox.js b/fix-sandbox.js new file mode 100644 index 0000000..2e99251 --- /dev/null +++ b/fix-sandbox.js @@ -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'); + } +} diff --git a/package.json b/package.json index 8caa6e7..2483a87 100644 --- a/package.json +++ b/package.json @@ -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", @@ -221,5 +224,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..f48e15c --- /dev/null +++ b/src/main/modules/mpris.ts @@ -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); + } + } + ); +} diff --git a/src/main/types/mpris-service.d.ts b/src/main/types/mpris-service.d.ts new file mode 100644 index 0000000..bb92c97 --- /dev/null +++ b/src/main/types/mpris-service.d.ts @@ -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; + position: number; + getPosition: () => number; + seeked(position: number): void; + objectPath(path: string): string; + quit(): void; + } + + function Player(options: PlayerOptions): Player; + export = Player; +} diff --git a/src/renderer/hooks/MusicHook.ts b/src/renderer/hooks/MusicHook.ts index 145e25e..49a7b37 100644 --- a/src/renderer/hooks/MusicHook.ts +++ b/src/renderer/hooks/MusicHook.ts @@ -52,6 +52,11 @@ export const textColors = ref(getTextColors()); export let playMusic: ComputedRef; export let artistList: ComputedRef; +let lastIndex = -1; + +// 缓存平台信息,避免每次歌词变化时同步 IPC 调用 +const cachedPlatform = isElectron ? window.electron.ipcRenderer.sendSync('get-platform') : 'web'; + export const musicDB = await useIndexedDB( 'musicDB', [ @@ -329,6 +334,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 +383,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 +440,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 +832,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; diff --git a/src/renderer/utils/appShortcuts.ts b/src/renderer/utils/appShortcuts.ts index 491a19f..819a3c9 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,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); }