diff --git a/electron/main.ts b/electron/main.ts index 8cade14..a04dc4c 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -16,6 +16,7 @@ import { annualReportService } from './services/annualReportService' import { exportService, ExportOptions } from './services/exportService' import { KeyService } from './services/keyService' import { voiceTranscribeService } from './services/voiceTranscribeService' +import { videoService } from './services/videoService' // 配置自动更新 @@ -200,6 +201,107 @@ function createOnboardingWindow() { return onboardingWindow } +/** + * 创建独立的视频播放窗口 + * 窗口大小会根据视频比例自动调整 + */ +function createVideoPlayerWindow(videoPath: string, videoWidth?: number, videoHeight?: number) { + const isDev = !!process.env.VITE_DEV_SERVER_URL + const iconPath = isDev + ? join(__dirname, '../public/icon.ico') + : join(process.resourcesPath, 'icon.ico') + + // 获取屏幕尺寸 + const { screen } = require('electron') + const primaryDisplay = screen.getPrimaryDisplay() + const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize + + // 计算窗口尺寸,只有标题栏 40px,控制栏悬浮 + let winWidth = 854 + let winHeight = 520 + const titleBarHeight = 40 + + if (videoWidth && videoHeight && videoWidth > 0 && videoHeight > 0) { + const aspectRatio = videoWidth / videoHeight + + const maxWidth = Math.floor(screenWidth * 0.85) + const maxHeight = Math.floor(screenHeight * 0.85) + + if (aspectRatio >= 1) { + // 横向视频 + winWidth = Math.min(videoWidth, maxWidth) + winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight + + if (winHeight > maxHeight) { + winHeight = maxHeight + winWidth = Math.floor((winHeight - titleBarHeight) * aspectRatio) + } + } else { + // 竖向视频 + const videoDisplayHeight = Math.min(videoHeight, maxHeight - titleBarHeight) + winHeight = videoDisplayHeight + titleBarHeight + winWidth = Math.floor(videoDisplayHeight * aspectRatio) + + if (winWidth < 300) { + winWidth = 300 + winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight + } + } + + winWidth = Math.max(winWidth, 360) + winHeight = Math.max(winHeight, 280) + } + + const win = new BrowserWindow({ + width: winWidth, + height: winHeight, + minWidth: 360, + minHeight: 280, + icon: iconPath, + webPreferences: { + preload: join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + webSecurity: false + }, + titleBarStyle: 'hidden', + titleBarOverlay: { + color: '#1a1a1a', + symbolColor: '#ffffff', + height: 40 + }, + show: false, + backgroundColor: '#000000', + autoHideMenuBar: true + }) + + win.once('ready-to-show', () => { + win.show() + }) + + const videoParam = `videoPath=${encodeURIComponent(videoPath)}` + if (process.env.VITE_DEV_SERVER_URL) { + win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/video-player-window?${videoParam}`) + + win.webContents.on('before-input-event', (event, input) => { + if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) { + if (win.webContents.isDevToolsOpened()) { + win.webContents.closeDevTools() + } else { + win.webContents.openDevTools() + } + event.preventDefault() + } + }) + } else { + win.loadFile(join(__dirname, '../dist/index.html'), { + hash: `/video-player-window?${videoParam}` + }) + } + + return win +} + function showMainWindow() { shouldShowMain = true if (mainWindowReady) { @@ -356,6 +458,79 @@ function registerIpcHandlers() { } }) + // 打开视频播放窗口 + ipcMain.handle('window:openVideoPlayerWindow', (_, videoPath: string, videoWidth?: number, videoHeight?: number) => { + createVideoPlayerWindow(videoPath, videoWidth, videoHeight) + }) + + // 根据视频尺寸调整窗口大小 + ipcMain.handle('window:resizeToFitVideo', (event, videoWidth: number, videoHeight: number) => { + const win = BrowserWindow.fromWebContents(event.sender) + if (!win || !videoWidth || !videoHeight) return + + const { screen } = require('electron') + const primaryDisplay = screen.getPrimaryDisplay() + const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize + + // 只有标题栏 40px,控制栏悬浮在视频上 + const titleBarHeight = 40 + const aspectRatio = videoWidth / videoHeight + + const maxWidth = Math.floor(screenWidth * 0.85) + const maxHeight = Math.floor(screenHeight * 0.85) + + let winWidth: number + let winHeight: number + + if (aspectRatio >= 1) { + // 横向视频 - 以宽度为基准 + winWidth = Math.min(videoWidth, maxWidth) + winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight + + if (winHeight > maxHeight) { + winHeight = maxHeight + winWidth = Math.floor((winHeight - titleBarHeight) * aspectRatio) + } + } else { + // 竖向视频 - 以高度为基准 + const videoDisplayHeight = Math.min(videoHeight, maxHeight - titleBarHeight) + winHeight = videoDisplayHeight + titleBarHeight + winWidth = Math.floor(videoDisplayHeight * aspectRatio) + + // 确保宽度不会太窄 + if (winWidth < 300) { + winWidth = 300 + winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight + } + } + + winWidth = Math.max(winWidth, 360) + winHeight = Math.max(winHeight, 280) + + // 调整窗口大小并居中 + win.setSize(winWidth, winHeight) + win.center() + }) + + // 视频相关 + ipcMain.handle('video:getVideoInfo', async (_, videoMd5: string) => { + try { + const result = await videoService.getVideoInfo(videoMd5) + return { success: true, ...result } + } catch (e) { + return { success: false, error: String(e), exists: false } + } + }) + + ipcMain.handle('video:parseVideoMd5', async (_, content: string) => { + try { + const md5 = videoService.parseVideoMd5(content) + return { success: true, md5 } + } catch (e) { + return { success: false, error: String(e) } + } + }) + // 数据库路径相关 ipcMain.handle('dbpath:autoDetect', async () => { return dbPathService.autoDetect() @@ -446,8 +621,8 @@ function registerIpcHandlers() { return chatService.resolveVoiceCache(sessionId, msgId) }) - ipcMain.handle('chat:getVoiceTranscript', async (event, sessionId: string, msgId: string) => { - return chatService.getVoiceTranscript(sessionId, msgId, (text) => { + ipcMain.handle('chat:getVoiceTranscript', async (event, sessionId: string, msgId: string, createTime?: number) => { + return chatService.getVoiceTranscript(sessionId, msgId, createTime, (text) => { event.sender.send('chat:voiceTranscriptPartial', { msgId, text }) }) }) diff --git a/electron/preload.ts b/electron/preload.ts index 775e19a..f04e6f4 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -53,7 +53,11 @@ contextBridge.exposeInMainWorld('electronAPI', { openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'), completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'), openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'), - setTitleBarOverlay: (options: { symbolColor: string }) => ipcRenderer.send('window:setTitleBarOverlay', options) + setTitleBarOverlay: (options: { symbolColor: string }) => ipcRenderer.send('window:setTitleBarOverlay', options), + openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => + ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight), + resizeToFitVideo: (videoWidth: number, videoHeight: number) => + ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight) }, // 数据库路径 @@ -109,7 +113,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime, serverId), resolveVoiceCache: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:resolveVoiceCache', sessionId, msgId), - getVoiceTranscript: (sessionId: string, msgId: string) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId), + getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceTranscript', sessionId, msgId, createTime), onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => { const listener = (_: any, payload: { msgId: string; text: string }) => callback(payload) ipcRenderer.on('chat:voiceTranscriptPartial', listener) @@ -137,6 +141,12 @@ contextBridge.exposeInMainWorld('electronAPI', { } }, + // 视频 + video: { + getVideoInfo: (videoMd5: string) => ipcRenderer.invoke('video:getVideoInfo', videoMd5), + parseVideoMd5: (content: string) => ipcRenderer.invoke('video:parseVideoMd5', content) + }, + // 数据分析 analytics: { getOverallStatistics: () => ipcRenderer.invoke('analytics:getOverallStatistics'), diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index cfe251b..330cf57 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -42,6 +42,7 @@ export interface Message { senderUsername: string | null parsedContent: string rawContent: string + content?: string // 原始XML内容(与rawContent相同,供前端使用) // 表情包相关 emojiCdnUrl?: string emojiMd5?: string @@ -52,6 +53,7 @@ export interface Message { // 图片/视频相关 imageMd5?: string imageDatName?: string + videoMd5?: string aesKey?: string encrypVer?: number cdnThumbUrl?: string @@ -83,7 +85,18 @@ class ChatService { private voiceWavCache = new Map() private voiceTranscriptCache = new Map() private voiceTranscriptPending = new Map>() + private mediaDbsCache: string[] | null = null + private mediaDbsCacheTime = 0 + private readonly mediaDbsCacheTtl = 300000 // 5分钟 private readonly voiceCacheMaxEntries = 50 + // 缓存 media.db 的表结构信息 + private mediaDbSchemaCache = new Map() constructor() { this.configService = new ConfigService() @@ -140,6 +153,10 @@ class ChatService { } this.connected = true + + // 预热 listMediaDbs 缓存(后台异步执行,不阻塞连接) + this.warmupMediaDbsCache() + return { success: true } } catch (e) { console.error('ChatService: 连接数据库失败:', e) @@ -147,6 +164,21 @@ class ChatService { } } + /** + * 预热 media 数据库列表缓存(后台异步执行) + */ + private async warmupMediaDbsCache(): Promise { + try { + const result = await wcdbService.listMediaDbs() + if (result.success && result.data) { + this.mediaDbsCache = result.data as string[] + this.mediaDbsCacheTime = Date.now() + } + } catch (e) { + // 静默失败,不影响主流程 + } + } + private async ensureConnected(): Promise<{ success: boolean; error?: string }> { if (this.connected && wcdbService.isReady()) { return { success: true } @@ -382,8 +414,6 @@ class ChatService { const needNewCursor = !state || offset === 0 || state.batchSize !== batchSize if (needNewCursor) { - console.log(`[ChatService] 创建新游标: sessionId=${sessionId}, offset=${offset}, batchSize=${batchSize}`) - // 关闭旧游标 if (state) { try { @@ -440,7 +470,6 @@ class ChatService { } // 获取当前批次的消息 - console.log(`[ChatService] 获取消息批次: cursor=${state.cursor}, fetched=${state.fetched}`) const batch = await wcdbService.fetchMessageBatch(state.cursor) if (!batch.success) { console.error('[ChatService] 获取消息批次失败:', batch.error) @@ -716,6 +745,7 @@ class ChatService { let quotedSender: string | undefined let imageMd5: string | undefined let imageDatName: string | undefined + let videoMd5: string | undefined let aesKey: string | undefined let encrypVer: number | undefined let cdnThumbUrl: string | undefined @@ -732,6 +762,9 @@ class ChatService { encrypVer = imageInfo.encrypVer cdnThumbUrl = imageInfo.cdnThumbUrl imageDatName = this.parseImageDatNameFromRow(row) + } else if (localType === 43 && content) { + // 视频消息 + videoMd5 = this.parseVideoMd5(content) } else if (localType === 34 && content) { voiceDurationSeconds = this.parseVoiceDurationSeconds(content) } else if (localType === 244813135921 || (content && content.includes('57'))) { @@ -756,6 +789,7 @@ class ChatService { quotedSender, imageMd5, imageDatName, + videoMd5, voiceDurationSeconds, aesKey, encrypVer, @@ -937,6 +971,26 @@ class ChatService { } } + /** + * 解析视频MD5 + * 注意:提取 md5 字段用于查询 hardlink.db,获取实际视频文件名 + */ + private parseVideoMd5(content: string): string | undefined { + if (!content) return undefined + + try { + // 提取 md5,这是用于查询 hardlink.db 的值 + const md5 = + this.extractXmlAttribute(content, 'videomsg', 'md5') || + this.extractXmlValue(content, 'md5') || + undefined + + return md5?.toLowerCase() + } catch { + return undefined + } + } + /** * 解析通话消息 * 格式: 0/1... @@ -1419,21 +1473,22 @@ class ChatService { } private extractXmlAttribute(xml: string, tagName: string, attrName: string): string { - const tagRegex = new RegExp(`<${tagName}[^>]*>`, 'i') - const tagMatch = tagRegex.exec(xml) - if (!tagMatch) return '' - - const attrRegex = new RegExp(`${attrName}\\s*=\\s*['"]([^'"]*)['"]`, 'i') - const attrMatch = attrRegex.exec(tagMatch[0]) - return attrMatch ? attrMatch[1] : '' + // 匹配 + const regex = new RegExp(`<${tagName}[^>]*\\s${attrName}\\s*=\\s*['"]([^'"]*)['"']`, 'i') + const match = regex.exec(xml) + return match ? match[1] : '' } private cleanSystemMessage(content: string): string { - return content - .replace(/]*>/gi, '') - .replace(/<\/?[a-zA-Z0-9_]+[^>]*>/g, '') - .replace(/\s+/g, ' ') - .trim() || '[系统消息]' + // 移除 XML 声明 + let cleaned = content.replace(/<\?xml[^?]*\?>/gi, '') + // 移除所有 XML/HTML 标签 + cleaned = cleaned.replace(/<[^>]+>/g, '') + // 移除尾部的数字(如撤回消息后的时间戳) + cleaned = cleaned.replace(/\d+\s*$/, '') + // 清理多余空白 + cleaned = cleaned.replace(/\s+/g, ' ').trim() + return cleaned || '[系统消息]' } private stripSenderPrefix(content: string): string { @@ -1691,21 +1746,17 @@ class ChatService { // 增加 'self' 作为兜底标识符,微信有时将个人信息存储在 'self' 记录中 const fetchList = Array.from(new Set([myWxid, cleanedWxid, 'self'])) - console.log(`[ChatService] 尝试获取个人头像, wxids: ${JSON.stringify(fetchList)}`) const result = await wcdbService.getAvatarUrls(fetchList) if (result.success && result.map) { // 按优先级尝试匹配 const avatarUrl = result.map[myWxid] || result.map[cleanedWxid] || result.map['self'] if (avatarUrl) { - console.log(`[ChatService] 成功获取个人头像: ${avatarUrl.substring(0, 50)}...`) return { success: true, avatarUrl } } - console.warn(`[ChatService] 未能在 contact.db 中找到个人头像, 请求列表: ${JSON.stringify(fetchList)}`) return { success: true, avatarUrl: undefined } } - console.error(`[ChatService] 查询个人头像失败: ${result.error || '未知错误'}`) return { success: true, avatarUrl: undefined } } catch (e) { console.error('ChatService: 获取当前用户头像失败:', e) @@ -1716,6 +1767,19 @@ class ChatService { /** * 获取表情包缓存目录 */ + /** + * 获取语音缓存目录 + */ + private getVoiceCacheDir(): string { + const cachePath = this.configService.get('cachePath') + if (cachePath) { + return join(cachePath, 'Voices') + } + // 回退到默认目录 + const documentsPath = app.getPath('documents') + return join(documentsPath, 'WeFlow', 'Voices') + } + private getEmojiCacheDir(): string { const cachePath = this.configService.get('cachePath') if (cachePath) { @@ -2085,12 +2149,6 @@ class ChatService { return { success: false, error: '未找到消息' } } const msg = msgResult.message - console.info('[ChatService][Image] request', { - sessionId, - localId: msg.localId, - imageMd5: msg.imageMd5, - imageDatName: msg.imageDatName - }) // 2. 确定搜索的基础名 const baseName = msg.imageMd5 || msg.imageDatName || String(msg.localId) @@ -2107,7 +2165,6 @@ class ChatService { const datPath = await this.findDatFile(actualAccountDir, baseName, sessionId) if (!datPath) return { success: false, error: '未找到图片源文件 (.dat)' } - console.info('[ChatService][Image] dat path', datPath) // 4. 获取解密密钥 const xorKeyRaw = this.configService.get('imageXorKey') @@ -2135,7 +2192,6 @@ class ChatService { const aesKey = this.asciiKey16(trimmed) decrypted = this.decryptDatV4(data, xorKey, aesKey) } - console.info('[ChatService][Image] decrypted bytes', decrypted.length) // 返回 base64 return { success: true, data: decrypted.toString('base64') } @@ -2146,44 +2202,30 @@ class ChatService { } /** - * getVoiceData (优化的 C++ 实现 + 文件缓存) + * getVoiceData (绕过WCDB的buggy getVoiceData,直接用execQuery读取) */ async getVoiceData(sessionId: string, msgId: string, createTime?: number, serverId?: string | number): Promise<{ success: boolean; data?: string; error?: string }> { - + const startTime = Date.now() try { const localId = parseInt(msgId, 10) if (isNaN(localId)) { return { success: false, error: '无效的消息ID' } } - // 检查文件缓存 - const cacheKey = this.getVoiceCacheKey(sessionId, msgId) - const cachedFile = this.getVoiceCacheFilePath(cacheKey) - if (existsSync(cachedFile)) { - try { - const wavData = readFileSync(cachedFile) - console.info('[ChatService][Voice] 使用缓存文件:', cachedFile) - return { success: true, data: wavData.toString('base64') } - } catch (e) { - console.error('[ChatService][Voice] 读取缓存失败:', e) - // 继续重新解密 - } - } - - // 1. 确定 createTime 和 svrId let msgCreateTime = createTime - let msgSvrId: string | number = serverId || 0 + let senderWxid: string | null = null - // 如果提供了传来的参数,验证其有效性 - if (!msgCreateTime || msgCreateTime === 0) { + // 如果前端没传 createTime,才需要查询消息(这个很慢) + if (!msgCreateTime) { + const t1 = Date.now() const msgResult = await this.getMessageByLocalId(sessionId, localId) + const t2 = Date.now() + console.log(`[Voice] getMessageByLocalId: ${t2 - t1}ms`) + if (msgResult.success && msgResult.message) { const msg = msgResult.message as any - msgCreateTime = msg.createTime || msg.create_time - // 尝试获取各种可能的 server id 列名 (只有在没有传入 serverId 时才查找) - if (!msgSvrId || msgSvrId === 0) { - msgSvrId = msg.serverId || msg.svr_id || msg.msg_svr_id || msg.message_id || 0 - } + msgCreateTime = msg.createTime + senderWxid = msg.senderUsername || null } } @@ -2191,54 +2233,84 @@ class ChatService { return { success: false, error: '未找到消息时间戳' } } - // 2. 构建查找候选 (sessionId, myWxid) + // 使用 sessionId + createTime 作为缓存key + const cacheKey = `${sessionId}_${msgCreateTime}` + + // 检查 WAV 内存缓存 + const wavCache = this.voiceWavCache.get(cacheKey) + if (wavCache) { + console.log(`[Voice] 内存缓存命中,总耗时: ${Date.now() - startTime}ms`) + return { success: true, data: wavCache.toString('base64') } + } + + // 检查 WAV 文件缓存 + const voiceCacheDir = this.getVoiceCacheDir() + const wavFilePath = join(voiceCacheDir, `${cacheKey}.wav`) + if (existsSync(wavFilePath)) { + try { + const wavData = readFileSync(wavFilePath) + // 同时缓存到内存 + this.cacheVoiceWav(cacheKey, wavData) + console.log(`[Voice] 文件缓存命中,总耗时: ${Date.now() - startTime}ms`) + return { success: true, data: wavData.toString('base64') } + } catch (e) { + console.error('[Voice] 读取缓存文件失败:', e) + } + } + + // 构建查找候选 const candidates: string[] = [] - if (sessionId) candidates.push(sessionId) const myWxid = this.configService.get('myWxid') as string + + // 如果有 senderWxid,优先使用(群聊中最重要) + if (senderWxid) { + candidates.push(senderWxid) + } + + // sessionId(1对1聊天时是对方wxid,群聊时是群id) + if (sessionId && !candidates.includes(sessionId)) { + candidates.push(sessionId) + } + + // 我的wxid(兜底) if (myWxid && !candidates.includes(myWxid)) { candidates.push(myWxid) } + const t3 = Date.now() + // 从数据库读取 silk 数据 + const silkData = await this.getVoiceDataFromMediaDb(msgCreateTime, candidates) + const t4 = Date.now() + console.log(`[Voice] getVoiceDataFromMediaDb: ${t4 - t3}ms`) - - // 3. 调用 C++ 接口获取语音 (Hex) - const voiceRes = await wcdbService.getVoiceData(sessionId, msgCreateTime, candidates, localId, msgSvrId) - if (!voiceRes.success || !voiceRes.hex) { - return { success: false, error: voiceRes.error || '未找到语音数据' } + if (!silkData) { + return { success: false, error: '未找到语音数据' } } + const t5 = Date.now() + // 使用 silk-wasm 解码 + const pcmData = await this.decodeSilkToPcm(silkData, 24000) + const t6 = Date.now() + console.log(`[Voice] decodeSilkToPcm: ${t6 - t5}ms`) - - // 4. Hex 转 Buffer (Silk) - const silkData = Buffer.from(voiceRes.hex, 'hex') - - // 5. 使用 silk-wasm 解码 - try { - const pcmData = await this.decodeSilkToPcm(silkData, 24000) - if (!pcmData) { - return { success: false, error: 'Silk 解码失败' } - } - - // PCM -> WAV - const wavData = this.createWavBuffer(pcmData, 24000) - - // 保存到文件缓存 - try { - this.saveVoiceCache(cacheKey, wavData) - console.info('[ChatService][Voice] 已保存缓存:', cachedFile) - } catch (e) { - console.error('[ChatService][Voice] 保存缓存失败:', e) - // 不影响返回 - } - - // 缓存 WAV 数据 (内存缓存) - this.cacheVoiceWav(cacheKey, wavData) - - return { success: true, data: wavData.toString('base64') } - } catch (e) { - console.error('[ChatService][Voice] decoding error:', e) - return { success: false, error: '语音解码失败: ' + String(e) } + if (!pcmData) { + return { success: false, error: 'Silk 解码失败' } } + + const t7 = Date.now() + // PCM -> WAV + const wavData = this.createWavBuffer(pcmData, 24000) + const t8 = Date.now() + console.log(`[Voice] createWavBuffer: ${t8 - t7}ms`) + + // 缓存 WAV 数据到内存 + this.cacheVoiceWav(cacheKey, wavData) + + // 缓存 WAV 数据到文件(异步,不阻塞返回) + this.cacheVoiceWavToFile(cacheKey, wavData) + + console.log(`[Voice] 总耗时: ${Date.now() - startTime}ms`) + return { success: true, data: wavData.toString('base64') } } catch (e) { console.error('ChatService: getVoiceData 失败:', e) return { success: false, error: String(e) } @@ -2246,26 +2318,228 @@ class ChatService { } /** - * 检查语音是否已有缓存 + * 缓存 WAV 数据到文件(异步) + */ + private async cacheVoiceWavToFile(cacheKey: string, wavData: Buffer): Promise { + try { + const voiceCacheDir = this.getVoiceCacheDir() + if (!existsSync(voiceCacheDir)) { + mkdirSync(voiceCacheDir, { recursive: true }) + } + + const wavFilePath = join(voiceCacheDir, `${cacheKey}.wav`) + writeFileSync(wavFilePath, wavData) + } catch (e) { + console.error('[Voice] 缓存文件失败:', e) + } + } + + /** + * 通过 WCDB 的 execQuery 直接查询 media.db(绕过有bug的getVoiceData接口) + * 策略:批量查询 + 多种兜底方案 + */ + private async getVoiceDataFromMediaDb(createTime: number, candidates: string[]): Promise { + const startTime = Date.now() + try { + const t1 = Date.now() + // 获取所有 media 数据库(永久缓存,直到应用重启) + let mediaDbFiles: string[] + if (this.mediaDbsCache) { + mediaDbFiles = this.mediaDbsCache + console.log(`[Voice] listMediaDbs (缓存): 0ms`) + } else { + const mediaDbsResult = await wcdbService.listMediaDbs() + const t2 = Date.now() + console.log(`[Voice] listMediaDbs: ${t2 - t1}ms`) + + if (!mediaDbsResult.success || !mediaDbsResult.data || mediaDbsResult.data.length === 0) { + return null + } + + mediaDbFiles = mediaDbsResult.data as string[] + this.mediaDbsCache = mediaDbFiles // 永久缓存 + } + + // 在所有 media 数据库中查找 + for (const dbPath of mediaDbFiles) { + try { + // 检查缓存 + let schema = this.mediaDbSchemaCache.get(dbPath) + + if (!schema) { + const t3 = Date.now() + // 第一次查询,获取表结构并缓存 + const tablesResult = await wcdbService.execQuery('media', dbPath, + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'VoiceInfo%'" + ) + const t4 = Date.now() + console.log(`[Voice] 查询VoiceInfo表: ${t4 - t3}ms`) + + if (!tablesResult.success || !tablesResult.rows || tablesResult.rows.length === 0) { + continue + } + + const voiceTable = tablesResult.rows[0].name + + const t5 = Date.now() + const columnsResult = await wcdbService.execQuery('media', dbPath, + `PRAGMA table_info('${voiceTable}')` + ) + const t6 = Date.now() + console.log(`[Voice] 查询表结构: ${t6 - t5}ms`) + + if (!columnsResult.success || !columnsResult.rows) { + continue + } + + // 创建列名映射(原始名称 -> 小写名称) + const columnMap = new Map() + for (const c of columnsResult.rows) { + const name = String(c.name || '') + if (name) { + columnMap.set(name.toLowerCase(), name) + } + } + + // 查找数据列(使用原始列名) + const dataColumnLower = ['voice_data', 'buf', 'voicebuf', 'data'].find(n => columnMap.has(n)) + const dataColumn = dataColumnLower ? columnMap.get(dataColumnLower) : undefined + + if (!dataColumn) { + continue + } + + // 查找 chat_name_id 列 + const chatNameIdColumnLower = ['chat_name_id', 'chatnameid', 'chat_nameid'].find(n => columnMap.has(n)) + const chatNameIdColumn = chatNameIdColumnLower ? columnMap.get(chatNameIdColumnLower) : undefined + + // 查找时间列 + const timeColumnLower = ['create_time', 'createtime', 'time'].find(n => columnMap.has(n)) + const timeColumn = timeColumnLower ? columnMap.get(timeColumnLower) : undefined + + const t7 = Date.now() + // 查找 Name2Id 表 + const name2IdTablesResult = await wcdbService.execQuery('media', dbPath, + "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Name2Id%'" + ) + const t8 = Date.now() + console.log(`[Voice] 查询Name2Id表: ${t8 - t7}ms`) + + const name2IdTable = (name2IdTablesResult.success && name2IdTablesResult.rows && name2IdTablesResult.rows.length > 0) + ? name2IdTablesResult.rows[0].name + : undefined + + schema = { + voiceTable, + dataColumn, + chatNameIdColumn, + timeColumn, + name2IdTable + } + + // 缓存表结构 + this.mediaDbSchemaCache.set(dbPath, schema) + } + + // 策略1: 通过 chat_name_id + create_time 查找(最准确) + if (schema.chatNameIdColumn && schema.timeColumn && schema.name2IdTable) { + const t9 = Date.now() + // 批量获取所有 candidates 的 chat_name_id(减少查询次数) + const candidatesStr = candidates.map(c => `'${c.replace(/'/g, "''")}'`).join(',') + const name2IdResult = await wcdbService.execQuery('media', dbPath, + `SELECT user_name, rowid FROM ${schema.name2IdTable} WHERE user_name IN (${candidatesStr})` + ) + const t10 = Date.now() + console.log(`[Voice] 查询chat_name_id: ${t10 - t9}ms`) + + if (name2IdResult.success && name2IdResult.rows && name2IdResult.rows.length > 0) { + // 构建 chat_name_id 列表 + const chatNameIds = name2IdResult.rows.map((r: any) => r.rowid) + const chatNameIdsStr = chatNameIds.join(',') + + const t11 = Date.now() + // 一次查询所有可能的语音 + const voiceResult = await wcdbService.execQuery('media', dbPath, + `SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.chatNameIdColumn} IN (${chatNameIdsStr}) AND ${schema.timeColumn} = ${createTime} LIMIT 1` + ) + const t12 = Date.now() + console.log(`[Voice] 策略1查询语音: ${t12 - t11}ms`) + + if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) { + const row = voiceResult.rows[0] + const silkData = this.decodeVoiceBlob(row.data) + if (silkData) { + console.log(`[Voice] getVoiceDataFromMediaDb总耗时: ${Date.now() - startTime}ms`) + return silkData + } + } + } + } + + // 策略2: 只通过 create_time 查找(兜底) + if (schema.timeColumn) { + const t13 = Date.now() + const voiceResult = await wcdbService.execQuery('media', dbPath, + `SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.timeColumn} = ${createTime} LIMIT 1` + ) + const t14 = Date.now() + console.log(`[Voice] 策略2查询语音: ${t14 - t13}ms`) + + if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) { + const row = voiceResult.rows[0] + const silkData = this.decodeVoiceBlob(row.data) + if (silkData) { + console.log(`[Voice] getVoiceDataFromMediaDb总耗时: ${Date.now() - startTime}ms`) + return silkData + } + } + } + + // 策略3: 时间范围查找(±5秒,处理时间戳不精确的情况) + if (schema.timeColumn) { + const t15 = Date.now() + const voiceResult = await wcdbService.execQuery('media', dbPath, + `SELECT ${schema.dataColumn} AS data FROM ${schema.voiceTable} WHERE ${schema.timeColumn} BETWEEN ${createTime - 5} AND ${createTime + 5} ORDER BY ABS(${schema.timeColumn} - ${createTime}) LIMIT 1` + ) + const t16 = Date.now() + console.log(`[Voice] 策略3查询语音: ${t16 - t15}ms`) + + if (voiceResult.success && voiceResult.rows && voiceResult.rows.length > 0) { + const row = voiceResult.rows[0] + const silkData = this.decodeVoiceBlob(row.data) + if (silkData) { + console.log(`[Voice] getVoiceDataFromMediaDb总耗时: ${Date.now() - startTime}ms`) + return silkData + } + } + } + } catch (e) { + // 静默失败,继续尝试下一个数据库 + } + } + + return null + } catch (e) { + return null + } + } + + /** + * 检查语音是否已有缓存(只检查内存,不查询数据库) */ async resolveVoiceCache(sessionId: string, msgId: string): Promise<{ success: boolean; hasCache: boolean; data?: string }> { try { + // 直接用 msgId 生成 cacheKey,不查询数据库 + // 注意:这里的 cacheKey 可能不准确(因为没有 createTime),但只是用来快速检查缓存 + // 如果缓存未命中,用户点击时会重新用正确的 cacheKey 查询 const cacheKey = this.getVoiceCacheKey(sessionId, msgId) - // 1. 检查内存缓存 + // 检查内存缓存 const inMemory = this.voiceWavCache.get(cacheKey) if (inMemory) { return { success: true, hasCache: true, data: inMemory.toString('base64') } } - // 2. 检查文件缓存 - const cachedFile = this.getVoiceCacheFilePath(cacheKey) - if (existsSync(cachedFile)) { - const wavData = readFileSync(cachedFile) - this.cacheVoiceWav(cacheKey, wavData) // 回甜内存 - return { success: true, hasCache: true, data: wavData.toString('base64') } - } - return { success: true, hasCache: false } } catch (e) { return { success: false, hasCache: false } @@ -2460,60 +2734,133 @@ class ChatService { async getVoiceTranscript( sessionId: string, msgId: string, + createTime?: number, onPartial?: (text: string) => void ): Promise<{ success: boolean; transcript?: string; error?: string }> { - const cacheKey = this.getVoiceCacheKey(sessionId, msgId) - const cached = this.voiceTranscriptCache.get(cacheKey) - if (cached) { - return { success: true, transcript: cached } - } + const startTime = Date.now() + console.log(`[Transcribe] 开始转写: sessionId=${sessionId}, msgId=${msgId}, createTime=${createTime}`) - const pending = this.voiceTranscriptPending.get(cacheKey) - if (pending) { - return pending - } + try { + let msgCreateTime = createTime + let serverId: string | number | undefined - const task = (async () => { - try { - let wavData = this.voiceWavCache.get(cacheKey) - if (!wavData) { - // 获取消息详情以拿到 createTime 和 serverId - let cTime: number | undefined - let sId: string | number | undefined - const msgResult = await this.getMessageById(sessionId, parseInt(msgId, 10)) - if (msgResult.success && msgResult.message) { - cTime = msgResult.message.createTime - sId = msgResult.message.serverId - } + // 如果前端没传 createTime,才需要查询消息(这个很慢) + if (!msgCreateTime) { + const t1 = Date.now() + const msgResult = await this.getMessageById(sessionId, parseInt(msgId, 10)) + const t2 = Date.now() + console.log(`[Transcribe] getMessageById: ${t2 - t1}ms`) - const voiceResult = await this.getVoiceData(sessionId, msgId, cTime, sId) - if (!voiceResult.success || !voiceResult.data) { - return { success: false, error: voiceResult.error || '语音解码失败' } - } - wavData = Buffer.from(voiceResult.data, 'base64') + if (msgResult.success && msgResult.message) { + msgCreateTime = msgResult.message.createTime + serverId = msgResult.message.serverId + console.log(`[Transcribe] 获取到 createTime=${msgCreateTime}, serverId=${serverId}`) } - - const result = await voiceTranscribeService.transcribeWavBuffer(wavData, (text) => { - onPartial?.(text) - }) - if (result.success && result.transcript) { - this.cacheVoiceTranscript(cacheKey, result.transcript) - } - return result - } catch (error) { - return { success: false, error: String(error) } - } finally { - this.voiceTranscriptPending.delete(cacheKey) } - })() - this.voiceTranscriptPending.set(cacheKey, task) - return task + if (!msgCreateTime) { + console.error(`[Transcribe] 未找到消息时间戳`) + return { success: false, error: '未找到消息时间戳' } + } + + // 使用正确的 cacheKey(包含 createTime) + const cacheKey = this.getVoiceCacheKey(sessionId, msgId, msgCreateTime) + console.log(`[Transcribe] cacheKey=${cacheKey}`) + + // 检查转写缓存 + const cached = this.voiceTranscriptCache.get(cacheKey) + if (cached) { + console.log(`[Transcribe] 缓存命中,总耗时: ${Date.now() - startTime}ms`) + return { success: true, transcript: cached } + } + + // 检查是否正在转写 + const pending = this.voiceTranscriptPending.get(cacheKey) + if (pending) { + console.log(`[Transcribe] 正在转写中,等待结果`) + return pending + } + + const task = (async () => { + try { + // 检查内存中是否有 WAV 数据 + let wavData = this.voiceWavCache.get(cacheKey) + if (wavData) { + console.log(`[Transcribe] WAV内存缓存命中,大小: ${wavData.length} bytes`) + } else { + // 检查文件缓存 + const voiceCacheDir = this.getVoiceCacheDir() + const wavFilePath = join(voiceCacheDir, `${cacheKey}.wav`) + if (existsSync(wavFilePath)) { + try { + wavData = readFileSync(wavFilePath) + console.log(`[Transcribe] WAV文件缓存命中,大小: ${wavData.length} bytes`) + // 同时缓存到内存 + this.cacheVoiceWav(cacheKey, wavData) + } catch (e) { + console.error(`[Transcribe] 读取缓存文件失败:`, e) + } + } + } + + if (!wavData) { + console.log(`[Transcribe] WAV缓存未命中,调用 getVoiceData`) + const t3 = Date.now() + // 调用 getVoiceData 获取并解码 + const voiceResult = await this.getVoiceData(sessionId, msgId, msgCreateTime, serverId) + const t4 = Date.now() + console.log(`[Transcribe] getVoiceData: ${t4 - t3}ms, success=${voiceResult.success}`) + + if (!voiceResult.success || !voiceResult.data) { + console.error(`[Transcribe] 语音解码失败: ${voiceResult.error}`) + return { success: false, error: voiceResult.error || '语音解码失败' } + } + wavData = Buffer.from(voiceResult.data, 'base64') + console.log(`[Transcribe] WAV数据大小: ${wavData.length} bytes`) + } + + // 转写 + console.log(`[Transcribe] 开始调用 transcribeWavBuffer`) + const t5 = Date.now() + const result = await voiceTranscribeService.transcribeWavBuffer(wavData, (text) => { + console.log(`[Transcribe] 部分结果: ${text}`) + onPartial?.(text) + }) + const t6 = Date.now() + console.log(`[Transcribe] transcribeWavBuffer: ${t6 - t5}ms, success=${result.success}`) + + if (result.success && result.transcript) { + console.log(`[Transcribe] 转写成功: ${result.transcript}`) + this.cacheVoiceTranscript(cacheKey, result.transcript) + } else { + console.error(`[Transcribe] 转写失败: ${result.error}`) + } + + console.log(`[Transcribe] 总耗时: ${Date.now() - startTime}ms`) + return result + } catch (error) { + console.error(`[Transcribe] 异常:`, error) + return { success: false, error: String(error) } + } finally { + this.voiceTranscriptPending.delete(cacheKey) + } + })() + + this.voiceTranscriptPending.set(cacheKey, task) + return task + } catch (error) { + console.error(`[Transcribe] 外层异常:`, error) + return { success: false, error: String(error) } + } } - private getVoiceCacheKey(sessionId: string, msgId: string): string { + private getVoiceCacheKey(sessionId: string, msgId: string, createTime?: number): string { + // 优先使用 createTime 作为key,避免不同会话中localId相同导致的混乱 + if (createTime) { + return `${sessionId}_${createTime}` + } return `${sessionId}_${msgId}` } @@ -2525,32 +2872,6 @@ class ChatService { } } - /** - * 获取语音缓存文件路径 - */ - private getVoiceCacheFilePath(cacheKey: string): string { - const cachePath = this.configService.get('cachePath') as string | undefined - let baseDir: string - if (cachePath && cachePath.trim()) { - baseDir = join(cachePath, 'Voices') - } else { - const documentsPath = app.getPath('documents') - baseDir = join(documentsPath, 'WeFlow', 'Voices') - } - if (!existsSync(baseDir)) { - mkdirSync(baseDir, { recursive: true }) - } - return join(baseDir, `${cacheKey}.wav`) - } - - /** - * 保存语音到文件缓存 - */ - private saveVoiceCache(cacheKey: string, wavData: Buffer): void { - const filePath = this.getVoiceCacheFilePath(cacheKey) - writeFileSync(filePath, wavData) - } - private cacheVoiceTranscript(cacheKey: string, transcript: string): void { this.voiceTranscriptCache.set(cacheKey, transcript) if (this.voiceTranscriptCache.size > this.voiceCacheMaxEntries) { @@ -2561,8 +2882,6 @@ class ChatService { async getMessageById(sessionId: string, localId: number): Promise<{ success: boolean; message?: Message; error?: string }> { try { - console.info('[ChatService] getMessageById (SQL)', { sessionId, localId }) - // 1. 获取该会话所在的消息表 // 注意:这里使用 getMessageTableStats 而不是 getMessageTables,因为前者包含 db_path const tableStats = await wcdbService.getMessageTableStats(sessionId) @@ -2585,7 +2904,6 @@ class ChatService { const message = this.parseMessage(row) if (message.localId !== 0) { - console.info('[ChatService] getMessageById hit', { tableName, localId: message.localId }) return { success: true, message } } } @@ -2628,6 +2946,7 @@ class ChatService { isSend: this.getRowInt(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'], 0), senderUsername: this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || null, rawContent: rawContent, + content: rawContent, // 添加原始内容供视频MD5解析使用 parsedContent: this.parseMessageContent(rawContent, this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0)) } diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index 7b6dab8..aae33f9 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -68,14 +68,7 @@ export class ImageDecryptService { const metaStr = meta ? ` ${JSON.stringify(meta)}` : '' const logLine = `[${timestamp}] [ImageDecrypt] ${message}${metaStr}\n` - // 同时输出到控制台 - if (meta) { - console.info(message, meta) - } else { - console.info(message) - } - - // 写入日志文件 + // 只写入文件,不输出到控制台 this.writeLog(logLine) } diff --git a/electron/services/videoService.ts b/electron/services/videoService.ts new file mode 100644 index 0000000..5420076 --- /dev/null +++ b/electron/services/videoService.ts @@ -0,0 +1,256 @@ +import { join } from 'path' +import { existsSync, readdirSync, statSync, readFileSync } from 'fs' +import { ConfigService } from './config' +import Database from 'better-sqlite3' +import { wcdbService } from './wcdbService' + +export interface VideoInfo { + videoUrl?: string // 视频文件路径(用于 readFile) + coverUrl?: string // 封面 data URL + thumbUrl?: string // 缩略图 data URL + exists: boolean +} + +class VideoService { + private configService: ConfigService + + constructor() { + this.configService = new ConfigService() + } + + /** + * 获取数据库根目录 + */ + private getDbPath(): string { + return this.configService.get('dbPath') || '' + } + + /** + * 获取当前用户的wxid + */ + private getMyWxid(): string { + return this.configService.get('myWxid') || '' + } + + /** + * 获取缓存目录(解密后的数据库存放位置) + */ + private getCachePath(): string { + return this.configService.get('cachePath') || '' + } + + /** + * 清理 wxid 目录名(去掉后缀) + */ + private cleanWxid(wxid: string): string { + const trimmed = wxid.trim() + if (!trimmed) return trimmed + + if (trimmed.toLowerCase().startsWith('wxid_')) { + const match = trimmed.match(/^(wxid_[^_]+)/i) + if (match) return match[1] + return trimmed + } + + const suffixMatch = trimmed.match(/^(.+)_([a-zA-Z0-9]{4})$/) + if (suffixMatch) return suffixMatch[1] + + return trimmed + } + + /** + * 从 video_hardlink_info_v4 表查询视频文件名 + * 优先使用 cachePath 中解密后的 hardlink.db(使用 better-sqlite3) + * 如果失败,则尝试使用 wcdbService.execQuery 查询加密的 hardlink.db + */ + private async queryVideoFileName(md5: string): Promise { + const cachePath = this.getCachePath() + const dbPath = this.getDbPath() + const wxid = this.getMyWxid() + const cleanedWxid = this.cleanWxid(wxid) + + if (!wxid) return undefined + + // 方法1:优先在 cachePath 下查找解密后的 hardlink.db + if (cachePath) { + const cacheDbPaths = [ + join(cachePath, cleanedWxid, 'hardlink.db'), + join(cachePath, wxid, 'hardlink.db'), + join(cachePath, 'hardlink.db'), + join(cachePath, 'databases', cleanedWxid, 'hardlink.db'), + join(cachePath, 'databases', wxid, 'hardlink.db') + ] + + for (const p of cacheDbPaths) { + if (existsSync(p)) { + try { + const db = new Database(p, { readonly: true }) + const row = db.prepare(` + SELECT file_name, md5 FROM video_hardlink_info_v4 + WHERE md5 = ? + LIMIT 1 + `).get(md5) as { file_name: string; md5: string } | undefined + db.close() + + if (row?.file_name) { + const realMd5 = row.file_name.replace(/\.[^.]+$/, '') + return realMd5 + } + } catch (e) { + // Silently fail + } + } + } + } + + // 方法2:使用 wcdbService.execQuery 查询加密的 hardlink.db + if (dbPath) { + const encryptedDbPaths = [ + join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db'), + join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db') + ] + + for (const p of encryptedDbPaths) { + if (existsSync(p)) { + try { + const escapedMd5 = md5.replace(/'/g, "''") + + // 用 md5 字段查询,获取 file_name + const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1` + + const result = await wcdbService.execQuery('media', p, sql) + + if (result.success && result.rows && result.rows.length > 0) { + const row = result.rows[0] + if (row?.file_name) { + // 提取不带扩展名的文件名作为实际视频 MD5 + const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '') + return realMd5 + } + } + } catch (e) { + } + } + } + } + return undefined + } + + /** + * 将文件转换为 data URL + */ + private fileToDataUrl(filePath: string, mimeType: string): string | undefined { + try { + if (!existsSync(filePath)) return undefined + const buffer = readFileSync(filePath) + return `data:${mimeType};base64,${buffer.toString('base64')}` + } catch { + return undefined + } + } + + /** + * 根据视频MD5获取视频文件信息 + * 视频存放在: {数据库根目录}/{用户wxid}/msg/video/{年月}/ + * 文件命名: {md5}.mp4, {md5}.jpg, {md5}_thumb.jpg + */ + async getVideoInfo(videoMd5: string): Promise { + + const dbPath = this.getDbPath() + const wxid = this.getMyWxid() + + if (!dbPath || !wxid || !videoMd5) { + return { exists: false } + } + + // 先尝试从数据库查询真正的视频文件名 + const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5 + + const videoBaseDir = join(dbPath, wxid, 'msg', 'video') + + if (!existsSync(videoBaseDir)) { + return { exists: false } + } + + // 遍历年月目录查找视频文件 + try { + const allDirs = readdirSync(videoBaseDir) + + // 支持多种目录格式: YYYY-MM, YYYYMM, 或其他 + const yearMonthDirs = allDirs + .filter(dir => { + const dirPath = join(videoBaseDir, dir) + return statSync(dirPath).isDirectory() + }) + .sort((a, b) => b.localeCompare(a)) // 从最新的目录开始查找 + + for (const yearMonth of yearMonthDirs) { + const dirPath = join(videoBaseDir, yearMonth) + + const videoPath = join(dirPath, `${realVideoMd5}.mp4`) + const coverPath = join(dirPath, `${realVideoMd5}.jpg`) + const thumbPath = join(dirPath, `${realVideoMd5}_thumb.jpg`) + + // 检查视频文件是否存在 + if (existsSync(videoPath)) { + return { + videoUrl: videoPath, // 返回文件路径,前端通过 readFile 读取 + coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'), + thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'), + exists: true + } + } + } + } catch (e) { + console.error('[VideoService] Error searching for video:', e) + } + + return { exists: false } + } + + /** + * 根据消息内容解析视频MD5 + */ + parseVideoMd5(content: string): string | undefined { + + // 打印前500字符看看 XML 结构 + + if (!content) return undefined + + try { + // 提取所有可能的 md5 值进行日志 + const allMd5s: string[] = [] + const md5Regex = /(?:md5|rawmd5|newmd5|originsourcemd5)\s*=\s*['"]([a-fA-F0-9]+)['"]/gi + let match + while ((match = md5Regex.exec(content)) !== null) { + allMd5s.push(`${match[0]}`) + } + + // 提取 md5(用于查询 hardlink.db) + // 注意:不是 rawmd5,rawmd5 是另一个值 + // 格式: md5="xxx" 或 xxx + + // 尝试从videomsg标签中提取md5 + const videoMsgMatch = /]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) + if (videoMsgMatch) { + return videoMsgMatch[1].toLowerCase() + } + + const attrMatch = /\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) + if (attrMatch) { + return attrMatch[1].toLowerCase() + } + + const md5Match = /([a-fA-F0-9]+)<\/md5>/i.exec(content) + if (md5Match) { + return md5Match[1].toLowerCase() + } + } catch (e) { + console.error('[VideoService] 解析视频MD5失败:', e) + } + + return undefined + } +} + +export const videoService = new VideoService() diff --git a/electron/services/voiceTranscribeService.ts b/electron/services/voiceTranscribeService.ts index a23d0d2..5f594c2 100644 --- a/electron/services/voiceTranscribeService.ts +++ b/electron/services/voiceTranscribeService.ts @@ -224,13 +224,16 @@ export class VoiceTranscribeService { let finalTranscript = '' worker.on('message', (msg: any) => { + console.log('[VoiceTranscribe] Worker 消息:', msg) if (msg.type === 'partial') { onPartial?.(msg.text) } else if (msg.type === 'final') { finalTranscript = msg.text + console.log('[VoiceTranscribe] 最终文本:', finalTranscript) resolve({ success: true, transcript: finalTranscript }) worker.terminate() } else if (msg.type === 'error') { + console.error('[VoiceTranscribe] Worker 错误:', msg.error) resolve({ success: false, error: msg.error }) worker.terminate() } diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index 1949e74..7f8f32c 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -110,7 +110,7 @@ export class WcdbCore { private writeLog(message: string, force = false): void { if (!force && !this.isLogEnabled()) return const line = `[${new Date().toISOString()}] ${message}` - console.log(`[WCDB] ${line}`) + // 移除控制台日志,只写入文件 try { const base = this.userDataPath || process.env.WCDB_LOG_DIR || process.cwd() const dir = join(base, 'logs') @@ -620,7 +620,7 @@ export class WcdbCore { try { this.wcdbSetMyWxid(this.handle, wxid) } catch (e) { - console.warn('设置 wxid 失败:', e) + // 静默失败 } } if (this.isLogEnabled()) { @@ -799,7 +799,6 @@ export class WcdbCore { await new Promise(resolve => setImmediate(resolve)) if (result !== 0 || !outPtr[0]) { - console.warn(`[wcdbCore] getAvatarUrls DLL调用失败: result=${result}, usernames=${toFetch.length}`) if (Object.keys(resultMap).length > 0) { return { success: true, map: resultMap, error: `获取头像失败: ${result}` } } @@ -807,25 +806,18 @@ export class WcdbCore { } const jsonStr = this.decodeJsonPtr(outPtr[0]) if (!jsonStr) { - console.error('[wcdbCore] getAvatarUrls 解析JSON失败') return { success: false, error: '解析头像失败' } } const map = JSON.parse(jsonStr) as Record - let successCount = 0 - let emptyCount = 0 for (const username of toFetch) { const url = map[username] if (url && url.trim()) { resultMap[username] = url // 只缓存有效的URL this.avatarUrlCache.set(username, { url, updatedAt: now }) - successCount++ - } else { - emptyCount++ - // 不缓存空URL,下次可以重新尝试 } + // 不缓存空URL,下次可以重新尝试 } - console.log(`[wcdbCore] getAvatarUrls 成功: ${successCount}个, 空结果: ${emptyCount}个, 总请求: ${toFetch.length}`) return { success: true, map: resultMap } } catch (e) { console.error('[wcdbCore] getAvatarUrls 异常:', e) diff --git a/resources/silk_v3_decoder.exe b/resources/silk_v3_decoder.exe deleted file mode 100644 index f612751..0000000 Binary files a/resources/silk_v3_decoder.exe and /dev/null differ diff --git a/src/App.tsx b/src/App.tsx index ed93702..77aa32f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ import GroupAnalyticsPage from './pages/GroupAnalyticsPage' import DataManagementPage from './pages/DataManagementPage' import SettingsPage from './pages/SettingsPage' import ExportPage from './pages/ExportPage' +import VideoWindow from './pages/VideoWindow' import { useAppStore } from './stores/appStore' import { themes, useThemeStore, type ThemeId } from './stores/themeStore' @@ -29,6 +30,7 @@ function App() { const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore() const isAgreementWindow = location.pathname === '/agreement-window' const isOnboardingWindow = location.pathname === '/onboarding-window' + const isVideoPlayerWindow = location.pathname === '/video-player-window' const [themeHydrated, setThemeHydrated] = useState(false) // 协议同意状态 @@ -219,6 +221,11 @@ function App() { return } + // 独立视频播放窗口 + if (isVideoPlayerWindow) { + return + } + // 主窗口 - 完整布局 return (
diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index f9e4db1..33e89bb 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -1,4 +1,4 @@ -.chat-page { +.chat-page { display: flex; height: 100%; gap: 16px; @@ -370,9 +370,23 @@ } .message-bubble { - max-width: 65%; + display: flex; + gap: 12px; + max-width: 80%; + margin-bottom: 4px; + align-items: flex-start; + + .bubble-body { + display: flex; + flex-direction: column; + max-width: 100%; + min-width: 0; // 允许收缩 + width: fit-content; // 让气泡宽度由内容决定 + } &.sent { + flex-direction: row-reverse; + .bubble-content { background: var(--primary-gradient); color: #fff; @@ -382,6 +396,10 @@ line-height: 1.5; box-shadow: 0 2px 10px var(--primary-light); } + + .bubble-body { + align-items: flex-end; + } } &.received { @@ -395,6 +413,10 @@ backdrop-filter: blur(10px); border: 1px solid var(--border-color); } + + .bubble-body { + align-items: flex-start; + } } &.system { @@ -428,6 +450,11 @@ font-size: 12px; color: var(--text-secondary); margin-bottom: 4px; + // 防止名字撑开气泡宽度 + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .quoted-message { @@ -790,6 +817,99 @@ } // 右侧消息区域 +// ... (previous content) ... + +// 链接卡片消息样式 +.link-message { + cursor: pointer; + background: var(--card-bg); + border-radius: 8px; + overflow: hidden; + border: 1px solid var(--border-color); + transition: all 0.2s ease; + max-width: 300px; + margin-top: 4px; + + &:hover { + background: var(--bg-hover); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + } + + .link-header { + display: flex; + align-items: flex-start; + padding: 12px; + gap: 12px; + } + + .link-content { + flex: 1; + min-width: 0; + } + + .link-title { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + margin-bottom: 4px; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.4; + } + + .link-desc { + font-size: 12px; + color: var(--text-secondary); + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: 1.4; + opacity: 0.8; + } + + .link-icon { + flex-shrink: 0; + width: 40px; + height: 40px; + background: var(--bg-tertiary); + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + + svg { + opacity: 0.8; + } + } +} + +// 适配发送出去的消息中的链接卡片 +.message-bubble.sent .link-message { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.2); + + .link-title, + .link-desc { + color: #fff; + } + + .link-icon { + background: rgba(255, 255, 255, 0.2); + color: #fff; + } + + &:hover { + background: rgba(255, 255, 255, 0.2); + } +} + .message-area { flex: 1 1 70%; display: flex; @@ -1485,6 +1605,11 @@ font-size: 12px; color: var(--text-tertiary); margin-bottom: 4px; + // 防止名字撑开气泡宽度 + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } // 引用消息样式 @@ -1533,7 +1658,11 @@ display: flex; flex-direction: column; max-width: 100%; + min-width: 0; // 允许收缩 -webkit-app-region: no-drag; + + // 让气泡宽度由内容决定,而不是被父容器撑开 + width: fit-content; } .bubble-content { @@ -1948,4 +2077,85 @@ width: 14px; height: 14px; } +} + +// 视频消息样式 +.video-thumb-wrapper { + position: relative; + max-width: 300px; + min-width: 200px; + border-radius: 12px; + overflow: hidden; + cursor: pointer; + background: var(--bg-tertiary); + transition: transform 0.2s; + + &:hover { + transform: scale(1.02); + + .video-play-button { + opacity: 1; + transform: translate(-50%, -50%) scale(1.1); + } + } + + .video-thumb { + width: 100%; + height: auto; + display: block; + } + + .video-thumb-placeholder { + width: 100%; + aspect-ratio: 16/9; + display: flex; + align-items: center; + justify-content: center; + background: var(--bg-hover); + color: var(--text-tertiary); + + svg { + width: 32px; + height: 32px; + } + } + + .video-play-button { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + opacity: 0.9; + transition: all 0.2s; + color: #fff; + filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.5)); + } +} + +.video-placeholder, +.video-loading, +.video-unavailable { + min-width: 120px; + min-height: 80px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 16px; + border-radius: 12px; + background: var(--bg-tertiary); + color: var(--text-tertiary); + font-size: 13px; + + svg { + width: 24px; + height: 24px; + } +} + +.video-loading { + .spin { + animation: spin 1s linear infinite; + } } \ No newline at end of file diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 74e655c..174c02f 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' -import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon } from 'lucide-react' +import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link } from 'lucide-react' import { createPortal } from 'react-dom' import { useChatStore } from '../stores/chatStore' import type { ChatSession, Message } from '../types/models' @@ -1343,6 +1343,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o const isSystem = isSystemMessage(message.localType) const isEmoji = message.localType === 47 const isImage = message.localType === 3 + const isVideo = message.localType === 43 const isVoice = message.localType === 34 const isSent = message.isSend === 1 const [senderAvatarUrl, setSenderAvatarUrl] = useState(undefined) @@ -1371,6 +1372,56 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o const [voiceWaveform, setVoiceWaveform] = useState([]) const voiceAutoDecryptTriggered = useRef(false) + // 视频相关状态 + const [videoLoading, setVideoLoading] = useState(false) + const [videoInfo, setVideoInfo] = useState<{ videoUrl?: string; coverUrl?: string; thumbUrl?: string; exists: boolean } | null>(null) + const videoContainerRef = useRef(null) + const [isVideoVisible, setIsVideoVisible] = useState(false) + const [videoMd5, setVideoMd5] = useState(null) + + // 解析视频 MD5 + useEffect(() => { + if (!isVideo) return + + console.log('[Video Debug] Full message object:', JSON.stringify(message, null, 2)) + console.log('[Video Debug] Message keys:', Object.keys(message)) + console.log('[Video Debug] Message:', { + localId: message.localId, + localType: message.localType, + hasVideoMd5: !!message.videoMd5, + hasContent: !!message.content, + hasParsedContent: !!message.parsedContent, + hasRawContent: !!(message as any).rawContent, + contentPreview: message.content?.substring(0, 200), + parsedContentPreview: message.parsedContent?.substring(0, 200), + rawContentPreview: (message as any).rawContent?.substring(0, 200) + }) + + // 优先使用数据库中的 videoMd5 + if (message.videoMd5) { + console.log('[Video Debug] Using videoMd5 from message:', message.videoMd5) + setVideoMd5(message.videoMd5) + return + } + + // 尝试从多个可能的字段获取原始内容 + const contentToUse = message.content || (message as any).rawContent || message.parsedContent + if (contentToUse) { + console.log('[Video Debug] Parsing MD5 from content, length:', contentToUse.length) + window.electronAPI.video.parseVideoMd5(contentToUse).then((result) => { + console.log('[Video Debug] Parse result:', result) + if (result && result.success && result.md5) { + console.log('[Video Debug] Parsed MD5:', result.md5) + setVideoMd5(result.md5) + } else { + console.error('[Video Debug] Failed to parse MD5:', result) + } + }).catch((err) => { + console.error('[Video Debug] Parse error:', err) + }) + } + }, [isVideo, message.videoMd5, message.content, message.parsedContent]) + // 加载自动转文字配置 useEffect(() => { const loadConfig = async () => { @@ -1784,7 +1835,16 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o throw error } - const result = await window.electronAPI.chat.getVoiceTranscript(session.username, String(message.localId)) + const result = await window.electronAPI.chat.getVoiceTranscript( + session.username, + String(message.localId), + message.createTime + ) + console.log('[ChatPage] 调用转写:', { + sessionId: session.username, + msgId: message.localId, + createTime: message.createTime + }) if (result.success) { const transcriptText = (result.transcript || '').trim() voiceTranscriptCache.set(voiceTranscriptCacheKey, transcriptText) @@ -1829,6 +1889,62 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o } }, [isVoice, message.localId, requestVoiceTranscript]) + // 视频懒加载 + useEffect(() => { + if (!isVideo || !videoContainerRef.current) return + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setIsVideoVisible(true) + observer.disconnect() + } + }) + }, + { + rootMargin: '200px 0px', + threshold: 0 + } + ) + + observer.observe(videoContainerRef.current) + + return () => observer.disconnect() + }, [isVideo]) + + // 加载视频信息 + useEffect(() => { + if (!isVideo || !isVideoVisible || videoInfo || videoLoading) return + if (!videoMd5) { + console.log('[Video Debug] No videoMd5 available yet') + return + } + + console.log('[Video Debug] Loading video info for MD5:', videoMd5) + setVideoLoading(true) + window.electronAPI.video.getVideoInfo(videoMd5).then((result) => { + console.log('[Video Debug] getVideoInfo result:', result) + if (result && result.success) { + setVideoInfo({ + exists: result.exists, + videoUrl: result.videoUrl, + coverUrl: result.coverUrl, + thumbUrl: result.thumbUrl + }) + } else { + console.error('[Video Debug] Video info failed:', result) + setVideoInfo({ exists: false }) + } + }).catch((err) => { + console.error('[Video Debug] getVideoInfo error:', err) + setVideoInfo({ exists: false }) + }).finally(() => { + setVideoLoading(false) + }) + }, [isVideo, isVideoVisible, videoInfo, videoLoading, videoMd5]) + + // 根据设置决定是否自动转写 const [autoTranscribeEnabled, setAutoTranscribeEnabled] = useState(false) @@ -1856,6 +1972,10 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o ) } + // 检测是否为链接卡片消息 + const isLinkMessage = String(message.localType) === '21474836529' || + (message.rawContent && (message.rawContent.includes(' 0 @@ -1959,6 +2080,72 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o ) } + // 视频消息 + if (isVideo) { + const handlePlayVideo = useCallback(async () => { + if (!videoInfo?.videoUrl) return + try { + await window.electronAPI.window.openVideoPlayerWindow(videoInfo.videoUrl) + } catch (e) { + console.error('打开视频播放窗口失败:', e) + } + }, [videoInfo?.videoUrl]) + + // 未进入可视区域时显示占位符 + if (!isVideoVisible) { + return ( +
+ + + + +
+ ) + } + + // 加载中 + if (videoLoading) { + return ( +
+ +
+ ) + } + + // 视频不存在 + if (!videoInfo?.exists || !videoInfo.videoUrl) { + return ( +
+ + + + + 视频不可用 +
+ ) + } + + // 默认显示缩略图,点击打开独立播放窗口 + const thumbSrc = videoInfo.thumbUrl || videoInfo.coverUrl + return ( +
+ {thumbSrc ? ( + 视频缩略图 + ) : ( +
+ + + + +
+ )} +
+ +
+
+ ) + } + if (isVoice) { const durationText = message.voiceDurationSeconds ? `${message.voiceDurationSeconds}"` : '' const handleToggle = async () => { @@ -2157,6 +2344,10 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o /> ) } + + // 解析引用消息(Links / App Messages) + // localType: 21474836529 corresponds to AppMessage which often contains links + // 带引用的消息 if (hasQuote) { return ( @@ -2169,6 +2360,68 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
) } + + // 解析引用消息(Links / App Messages) + // localType: 21474836529 corresponds to AppMessage which often contains links + if (isLinkMessage) { + try { + // 清理内容:移除可能的 wxid 前缀,找到 XML 起始位置 + let contentToParse = message.rawContent || message.parsedContent || ''; + const xmlStartIndex = contentToParse.indexOf('<'); + if (xmlStartIndex >= 0) { + contentToParse = contentToParse.substring(xmlStartIndex); + } + + // 处理 HTML 转义字符 + if (contentToParse.includes('<')) { + contentToParse = contentToParse + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'"); + } + + const parser = new DOMParser(); + const doc = parser.parseFromString(contentToParse, "text/xml"); + const appMsg = doc.querySelector('appmsg'); + + if (appMsg) { + const title = doc.querySelector('title')?.textContent || '未命名链接'; + const des = doc.querySelector('des')?.textContent || '无描述'; + const url = doc.querySelector('url')?.textContent || ''; + + return ( +
{ + e.stopPropagation(); + if (url) { + // 优先使用 electron 接口打开外部浏览器 + if (window.electronAPI?.shell?.openExternal) { + window.electronAPI.shell.openExternal(url); + } else { + window.open(url, '_blank'); + } + } + }} + > +
+
+
{title}
+
{des}
+
+
+ +
+
+
+ ); + } + } catch (e) { + console.error('Failed to parse app message', e); + } + } // 普通消息 return
{renderTextWithEmoji(cleanMessageContent(message.parsedContent))}
} diff --git a/src/pages/VideoWindow.scss b/src/pages/VideoWindow.scss new file mode 100644 index 0000000..592439a --- /dev/null +++ b/src/pages/VideoWindow.scss @@ -0,0 +1,216 @@ +.video-window-container { + width: 100vw; + height: 100vh; + background-color: #000; + display: flex; + flex-direction: column; + overflow: hidden; + user-select: none; + + .title-bar { + height: 40px; + min-height: 40px; + display: flex; + background: #1a1a1a; + padding-right: 140px; + position: relative; + z-index: 10; + + .window-drag-area { + flex: 1; + height: 100%; + -webkit-app-region: drag; + } + } + + .video-viewport { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + position: relative; + cursor: pointer; + background: #000; + overflow: hidden; + min-height: 0; // 重要:让 flex 子元素可以收缩 + + video { + max-width: 100%; + max-height: 100%; + width: auto; + height: auto; + object-fit: contain; + } + + .video-loading-overlay, + .video-error-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.5); + z-index: 5; + } + + .video-error-overlay { + color: #ff6b6b; + font-size: 14px; + } + + .spinner { + width: 40px; + height: 40px; + border: 3px solid rgba(255, 255, 255, 0.2); + border-top-color: #fff; + border-radius: 50%; + animation: spin 1s linear infinite; + } + + .play-overlay { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.3); + opacity: 0; + transition: opacity 0.2s; + z-index: 4; + + svg { + filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.5)); + } + } + + &:hover .play-overlay { + opacity: 1; + } + } + + .video-controls { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: linear-gradient(to top, rgba(0, 0, 0, 0.85), rgba(0, 0, 0, 0.4) 60%, transparent); + padding: 40px 16px 12px; + opacity: 0; + transition: opacity 0.25s; + z-index: 6; + + .progress-bar { + height: 16px; + display: flex; + align-items: center; + cursor: pointer; + margin-bottom: 8px; + + .progress-track { + flex: 1; + height: 3px; + background: rgba(255, 255, 255, 0.3); + border-radius: 2px; + overflow: hidden; + transition: height 0.15s; + + .progress-fill { + height: 100%; + background: var(--primary, #4a9eff); + border-radius: 2px; + } + } + + &:hover .progress-track { + height: 5px; + } + } + + .controls-row { + display: flex; + align-items: center; + justify-content: space-between; + } + + .controls-left, + .controls-right { + display: flex; + align-items: center; + gap: 6px; + } + + button { + background: transparent; + border: none; + color: #fff; + cursor: pointer; + padding: 6px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s; + + &:hover { + background: rgba(255, 255, 255, 0.15); + } + } + + .time-display { + color: rgba(255, 255, 255, 0.9); + font-size: 12px; + font-variant-numeric: tabular-nums; + margin-left: 4px; + } + + .volume-control { + display: flex; + align-items: center; + gap: 4px; + + .volume-slider { + width: 60px; + height: 3px; + appearance: none; + -webkit-appearance: none; + background: rgba(255, 255, 255, 0.3); + border-radius: 2px; + cursor: pointer; + + &::-webkit-slider-thumb { + appearance: none; + -webkit-appearance: none; + width: 10px; + height: 10px; + background: #fff; + border-radius: 50%; + cursor: pointer; + } + } + } + } + + // 鼠标悬停时显示控制栏 + &:hover .video-controls { + opacity: 1; + } + + // 播放时如果鼠标不动,隐藏控制栏 + &.hide-controls .video-controls { + opacity: 0; + } +} + +.video-window-empty { + height: 100vh; + display: flex; + align-items: center; + justify-content: center; + color: rgba(255, 255, 255, 0.6); + background-color: #000; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} diff --git a/src/pages/VideoWindow.tsx b/src/pages/VideoWindow.tsx new file mode 100644 index 0000000..5719548 --- /dev/null +++ b/src/pages/VideoWindow.tsx @@ -0,0 +1,199 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { useSearchParams } from 'react-router-dom' +import { Play, Pause, Volume2, VolumeX, RotateCcw } from 'lucide-react' +import './VideoWindow.scss' + +export default function VideoWindow() { + const [searchParams] = useSearchParams() + const videoPath = searchParams.get('videoPath') + const [isPlaying, setIsPlaying] = useState(false) + const [isMuted, setIsMuted] = useState(false) + const [currentTime, setCurrentTime] = useState(0) + const [duration, setDuration] = useState(0) + const [volume, setVolume] = useState(1) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + const videoRef = useRef(null) + const progressRef = useRef(null) + + // 格式化时间 + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60) + const secs = Math.floor(seconds % 60) + return `${mins}:${secs.toString().padStart(2, '0')}` + } + + //播放/暂停 + const togglePlay = useCallback(() => { + if (!videoRef.current) return + if (isPlaying) { + videoRef.current.pause() + } else { + videoRef.current.play() + } + }, [isPlaying]) + + // 静音切换 + const toggleMute = useCallback(() => { + if (!videoRef.current) return + videoRef.current.muted = !isMuted + setIsMuted(!isMuted) + }, [isMuted]) + + // 进度条点击 + const handleProgressClick = useCallback((e: React.MouseEvent) => { + if (!videoRef.current || !progressRef.current) return + e.stopPropagation() + const rect = progressRef.current.getBoundingClientRect() + const percent = (e.clientX - rect.left) / rect.width + videoRef.current.currentTime = percent * duration + }, [duration]) + + // 音量调节 + const handleVolumeChange = useCallback((e: React.ChangeEvent) => { + const newVolume = parseFloat(e.target.value) + setVolume(newVolume) + if (videoRef.current) { + videoRef.current.volume = newVolume + setIsMuted(newVolume === 0) + } + }, []) + + // 重新播放 + const handleReplay = useCallback(() => { + if (!videoRef.current) return + videoRef.current.currentTime = 0 + videoRef.current.play() + }, []) + + // 快捷键 + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') window.electronAPI.window.close() + if (e.key === ' ') { + e.preventDefault() + togglePlay() + } + if (e.key === 'm' || e.key === 'M') toggleMute() + if (e.key === 'ArrowLeft' && videoRef.current) { + videoRef.current.currentTime -= 5 + } + if (e.key === 'ArrowRight' && videoRef.current) { + videoRef.current.currentTime += 5 + } + if (e.key === 'ArrowUp' && videoRef.current) { + videoRef.current.volume = Math.min(1, videoRef.current.volume + 0.1) + setVolume(videoRef.current.volume) + } + if (e.key === 'ArrowDown' && videoRef.current) { + videoRef.current.volume = Math.max(0, videoRef.current.volume - 0.1) + setVolume(videoRef.current.volume) + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [togglePlay, toggleMute]) + + if (!videoPath) { + return ( +
+ 无效的视频路径 +
+ ) + } + + const progress = duration > 0 ? (currentTime / duration) * 100 : 0 + + return ( +
+
+
+
+ +
+ {isLoading && ( +
+
+
+ )} + {error && ( +
+ {error} +
+ )} +
+
+ ) +} diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 65cff90..e3528cf 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -9,6 +9,8 @@ export interface ElectronAPI { completeOnboarding: () => Promise openOnboardingWindow: () => Promise setTitleBarOverlay: (options: { symbolColor: string }) => void + openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => Promise + resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise } config: { get: (key: string) => Promise @@ -96,7 +98,7 @@ export interface ElectronAPI { getImageData: (sessionId: string, msgId: string) => Promise<{ success: boolean; data?: string; error?: string }> getVoiceData: (sessionId: string, msgId: string, createTime?: number, serverId?: string | number) => Promise<{ success: boolean; data?: string; error?: string }> resolveVoiceCache: (sessionId: string, msgId: string) => Promise<{ success: boolean; hasCache: boolean; data?: string }> - getVoiceTranscript: (sessionId: string, msgId: string) => Promise<{ success: boolean; transcript?: string; error?: string }> + getVoiceTranscript: (sessionId: string, msgId: string, createTime?: number) => Promise<{ success: boolean; transcript?: string; error?: string }> onVoiceTranscriptPartial: (callback: (payload: { msgId: string; text: string }) => void) => () => void } @@ -107,6 +109,21 @@ export interface ElectronAPI { onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: string }) => void) => () => void } + video: { + getVideoInfo: (videoMd5: string) => Promise<{ + success: boolean + exists: boolean + videoUrl?: string + coverUrl?: string + thumbUrl?: string + error?: string + }> + parseVideoMd5: (content: string) => Promise<{ + success: boolean + md5?: string + error?: string + }> + } analytics: { getOverallStatistics: (force?: boolean) => Promise<{ success: boolean diff --git a/src/types/models.ts b/src/types/models.ts index 09f6e41..46c3d42 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -33,11 +33,14 @@ export interface Message { isSend: number | null senderUsername: string | null parsedContent: string + rawContent?: string // 原始消息内容(保留用于兼容) + content?: string // 原始消息内容(XML) imageMd5?: string imageDatName?: string emojiCdnUrl?: string emojiMd5?: string voiceDurationSeconds?: number + videoMd5?: string // 引用消息 quotedContent?: string quotedSender?: string