From 240514f1e58a94f25540419f14b5cb919614db95 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Sun, 18 Jan 2026 23:19:58 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E4=BA=86=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E9=A1=B5=E9=9D=A2=E6=92=AD=E6=94=BE=E8=A7=86=E9=A2=91?= =?UTF-8?q?=E7=9A=84=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/main.ts | 175 ++++++++++++++++++ electron/preload.ts | 12 +- electron/services/chatService.ts | 125 +++++++------ electron/services/videoService.ts | 289 ++++++++++++++++++++++++++++++ src/App.tsx | 7 + src/pages/ChatPage.scss | 81 +++++++++ src/pages/ChatPage.tsx | 173 ++++++++++++++++++ src/pages/VideoWindow.scss | 216 ++++++++++++++++++++++ src/pages/VideoWindow.tsx | 199 ++++++++++++++++++++ src/types/electron.d.ts | 17 ++ src/types/models.ts | 2 + 11 files changed, 1245 insertions(+), 51 deletions(-) create mode 100644 electron/services/videoService.ts create mode 100644 src/pages/VideoWindow.scss create mode 100644 src/pages/VideoWindow.tsx diff --git a/electron/main.ts b/electron/main.ts index 5cec754..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() diff --git a/electron/preload.ts b/electron/preload.ts index c3aaf4a..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) }, // 数据库路径 @@ -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 19594c4..16d4af9 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 @@ -151,10 +153,10 @@ class ChatService { } this.connected = true - + // 预热 listMediaDbs 缓存(后台异步执行,不阻塞连接) this.warmupMediaDbsCache() - + return { success: true } } catch (e) { console.error('ChatService: 连接数据库失败:', e) @@ -743,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 @@ -759,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'))) { @@ -783,6 +789,7 @@ class ChatService { quotedSender, imageMd5, imageDatName, + videoMd5, voiceDurationSeconds, aesKey, encrypVer, @@ -964,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... @@ -1446,13 +1473,10 @@ 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 { @@ -2193,7 +2217,7 @@ class ChatService { 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 @@ -2233,17 +2257,17 @@ class ChatService { // 构建查找候选 const candidates: string[] = [] 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) @@ -2254,7 +2278,7 @@ class ChatService { const silkData = await this.getVoiceDataFromMediaDb(msgCreateTime, candidates) const t4 = Date.now() console.log(`[Voice] getVoiceDataFromMediaDb: ${t4 - t3}ms`) - + if (!silkData) { return { success: false, error: '未找到语音数据' } } @@ -2264,7 +2288,7 @@ class ChatService { const pcmData = await this.decodeSilkToPcm(silkData, 24000) const t6 = Date.now() console.log(`[Voice] decodeSilkToPcm: ${t6 - t5}ms`) - + if (!pcmData) { return { success: false, error: 'Silk 解码失败' } } @@ -2298,7 +2322,7 @@ class ChatService { if (!existsSync(voiceCacheDir)) { mkdirSync(voiceCacheDir, { recursive: true }) } - + const wavFilePath = join(voiceCacheDir, `${cacheKey}.wav`) writeFileSync(wavFilePath, wavData) } catch (e) { @@ -2323,11 +2347,11 @@ class ChatService { 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 // 永久缓存 } @@ -2337,33 +2361,33 @@ class ChatService { try { // 检查缓存 let schema = this.mediaDbSchemaCache.get(dbPath) - + if (!schema) { const t3 = Date.now() // 第一次查询,获取表结构并缓存 - const tablesResult = await wcdbService.execQuery('media', dbPath, + 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, + 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) { @@ -2372,23 +2396,23 @@ class ChatService { 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, @@ -2396,11 +2420,11 @@ class ChatService { ) 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, @@ -2408,11 +2432,11 @@ class ChatService { timeColumn, name2IdTable } - + // 缓存表结构 this.mediaDbSchemaCache.set(dbPath, schema) } - + // 策略1: 通过 chat_name_id + create_time 查找(最准确) if (schema.chatNameIdColumn && schema.timeColumn && schema.name2IdTable) { const t9 = Date.now() @@ -2423,12 +2447,12 @@ class ChatService { ) 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, @@ -2436,7 +2460,7 @@ class ChatService { ) 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) @@ -2447,7 +2471,7 @@ class ChatService { } } } - + // 策略2: 只通过 create_time 查找(兜底) if (schema.timeColumn) { const t13 = Date.now() @@ -2456,7 +2480,7 @@ class ChatService { ) 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) @@ -2466,7 +2490,7 @@ class ChatService { } } } - + // 策略3: 时间范围查找(±5秒,处理时间戳不精确的情况) if (schema.timeColumn) { const t15 = Date.now() @@ -2475,7 +2499,7 @@ class ChatService { ) 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) @@ -2711,18 +2735,18 @@ class ChatService { ): Promise<{ success: boolean; transcript?: string; error?: string }> { const startTime = Date.now() console.log(`[Transcribe] 开始转写: sessionId=${sessionId}, msgId=${msgId}, createTime=${createTime}`) - + try { let msgCreateTime = createTime let serverId: string | number | undefined - + // 如果前端没传 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`) - + if (msgResult.success && msgResult.message) { msgCreateTime = msgResult.message.createTime serverId = msgResult.message.serverId @@ -2738,7 +2762,7 @@ class ChatService { // 使用正确的 cacheKey(包含 createTime) const cacheKey = this.getVoiceCacheKey(sessionId, msgId, msgCreateTime) console.log(`[Transcribe] cacheKey=${cacheKey}`) - + // 检查转写缓存 const cached = this.voiceTranscriptCache.get(cacheKey) if (cached) { @@ -2774,7 +2798,7 @@ class ChatService { } } } - + if (!wavData) { console.log(`[Transcribe] WAV缓存未命中,调用 getVoiceData`) const t3 = Date.now() @@ -2782,7 +2806,7 @@ class ChatService { 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 || '语音解码失败' } @@ -2800,14 +2824,14 @@ class ChatService { }) 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) { @@ -2918,6 +2942,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/videoService.ts b/electron/services/videoService.ts new file mode 100644 index 0000000..2734334 --- /dev/null +++ b/electron/services/videoService.ts @@ -0,0 +1,289 @@ +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) + + console.log('[VideoService] queryVideoFileName called with MD5:', md5) + console.log('[VideoService] cachePath:', cachePath, 'dbPath:', dbPath, 'wxid:', wxid, 'cleanedWxid:', cleanedWxid) + + 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)) { + console.log('[VideoService] Found decrypted hardlink.db at:', 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(/\.[^.]+$/, '') + console.log('[VideoService] Found video filename via cache:', realMd5) + return realMd5 + } + } catch (e) { + console.log('[VideoService] Failed to query cached hardlink.db:', e) + } + } + } + } + + // 方法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)) { + console.log('[VideoService] Found encrypted hardlink.db at:', 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` + console.log('[VideoService] Query SQL:', sql) + + const result = await wcdbService.execQuery('media', p, sql) + console.log('[VideoService] Query result:', result) + + 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(/\.[^.]+$/, '') + console.log('[VideoService] Found video filename:', realMd5) + return realMd5 + } + } + } catch (e) { + console.log('[VideoService] Failed to query encrypted hardlink.db via wcdbService:', e) + } + } + } + } + + console.log('[VideoService] No matching video found in hardlink.db') + 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 { + console.log('[VideoService] getVideoInfo called with MD5:', videoMd5) + + const dbPath = this.getDbPath() + const wxid = this.getMyWxid() + + console.log('[VideoService] Config - dbPath:', dbPath, 'wxid:', wxid) + + if (!dbPath || !wxid || !videoMd5) { + console.log('[VideoService] Missing required params') + return { exists: false } + } + + // 先尝试从数据库查询真正的视频文件名 + const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5 + console.log('[VideoService] Real video MD5:', realVideoMd5) + + const videoBaseDir = join(dbPath, wxid, 'msg', 'video') + console.log('[VideoService] Video base dir:', videoBaseDir) + + if (!existsSync(videoBaseDir)) { + console.log('[VideoService] Video base dir does not exist') + return { exists: false } + } + + // 遍历年月目录查找视频文件 + try { + const allDirs = readdirSync(videoBaseDir) + console.log('[VideoService] Found year-month dirs:', allDirs) + + // 支持多种目录格式: 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`) + + console.log('[VideoService] Checking:', videoPath) + + // 检查视频文件是否存在 + if (existsSync(videoPath)) { + console.log('[VideoService] Video file found!') + return { + videoUrl: videoPath, // 返回文件路径,前端通过 readFile 读取 + coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'), + thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'), + exists: true + } + } + } + + console.log('[VideoService] Video file not found in any directory') + } catch (e) { + console.error('[VideoService] Error searching for video:', e) + } + + return { exists: false } + } + + /** + * 根据消息内容解析视频MD5 + */ + parseVideoMd5(content: string): string | undefined { + console.log('[VideoService] parseVideoMd5 called, content length:', content?.length) + + // 打印前500字符看看 XML 结构 + console.log('[VideoService] XML preview:', content?.substring(0, 500)) + + 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]}`) + } + console.log('[VideoService] All MD5 attributes found:', allMd5s) + + // 提取 md5(用于查询 hardlink.db) + // 注意:不是 rawmd5,rawmd5 是另一个值 + // 格式: md5="xxx" 或 xxx + + // 尝试从videomsg标签中提取md5 + const videoMsgMatch = /]*\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) + if (videoMsgMatch) { + console.log('[VideoService] Found MD5 via videomsg:', videoMsgMatch[1]) + return videoMsgMatch[1].toLowerCase() + } + + const attrMatch = /\smd5\s*=\s*['"]([a-fA-F0-9]+)['"]/i.exec(content) + if (attrMatch) { + console.log('[VideoService] Found MD5 via attribute:', attrMatch[1]) + return attrMatch[1].toLowerCase() + } + + const md5Match = /([a-fA-F0-9]+)<\/md5>/i.exec(content) + if (md5Match) { + console.log('[VideoService] Found MD5 via tag:', md5Match[1]) + return md5Match[1].toLowerCase() + } + + console.log('[VideoService] No MD5 found in content') + } catch (e) { + console.error('[VideoService] 解析视频MD5失败:', e) + } + + return undefined + } +} + +export const videoService = new VideoService() 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 45aca76..f6db4d7 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -1956,4 +1956,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: 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 1e11914..170ab2b 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -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 () => { @@ -1838,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) @@ -1968,6 +2075,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 () => { 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 34ee49d..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 @@ -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..a3d7695 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -33,11 +33,13 @@ export interface Message { isSend: number | null senderUsername: string | null parsedContent: string + content?: string // 原始消息内容(XML) imageMd5?: string imageDatName?: string emojiCdnUrl?: string emojiMd5?: string voiceDurationSeconds?: number + videoMd5?: string // 引用消息 quotedContent?: string quotedSender?: string