mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
feat: 新增了聊天页面播放视频的功能
This commit is contained in:
175
electron/main.ts
175
electron/main.ts
@@ -16,6 +16,7 @@ import { annualReportService } from './services/annualReportService'
|
|||||||
import { exportService, ExportOptions } from './services/exportService'
|
import { exportService, ExportOptions } from './services/exportService'
|
||||||
import { KeyService } from './services/keyService'
|
import { KeyService } from './services/keyService'
|
||||||
import { voiceTranscribeService } from './services/voiceTranscribeService'
|
import { voiceTranscribeService } from './services/voiceTranscribeService'
|
||||||
|
import { videoService } from './services/videoService'
|
||||||
|
|
||||||
|
|
||||||
// 配置自动更新
|
// 配置自动更新
|
||||||
@@ -200,6 +201,107 @@ function createOnboardingWindow() {
|
|||||||
return onboardingWindow
|
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() {
|
function showMainWindow() {
|
||||||
shouldShowMain = true
|
shouldShowMain = true
|
||||||
if (mainWindowReady) {
|
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 () => {
|
ipcMain.handle('dbpath:autoDetect', async () => {
|
||||||
return dbPathService.autoDetect()
|
return dbPathService.autoDetect()
|
||||||
|
|||||||
@@ -53,7 +53,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
|||||||
openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
|
openAgreementWindow: () => ipcRenderer.invoke('window:openAgreementWindow'),
|
||||||
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
|
completeOnboarding: () => ipcRenderer.invoke('window:completeOnboarding'),
|
||||||
openOnboardingWindow: () => ipcRenderer.invoke('window:openOnboardingWindow'),
|
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: {
|
analytics: {
|
||||||
getOverallStatistics: () => ipcRenderer.invoke('analytics:getOverallStatistics'),
|
getOverallStatistics: () => ipcRenderer.invoke('analytics:getOverallStatistics'),
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export interface Message {
|
|||||||
senderUsername: string | null
|
senderUsername: string | null
|
||||||
parsedContent: string
|
parsedContent: string
|
||||||
rawContent: string
|
rawContent: string
|
||||||
|
content?: string // 原始XML内容(与rawContent相同,供前端使用)
|
||||||
// 表情包相关
|
// 表情包相关
|
||||||
emojiCdnUrl?: string
|
emojiCdnUrl?: string
|
||||||
emojiMd5?: string
|
emojiMd5?: string
|
||||||
@@ -52,6 +53,7 @@ export interface Message {
|
|||||||
// 图片/视频相关
|
// 图片/视频相关
|
||||||
imageMd5?: string
|
imageMd5?: string
|
||||||
imageDatName?: string
|
imageDatName?: string
|
||||||
|
videoMd5?: string
|
||||||
aesKey?: string
|
aesKey?: string
|
||||||
encrypVer?: number
|
encrypVer?: number
|
||||||
cdnThumbUrl?: string
|
cdnThumbUrl?: string
|
||||||
@@ -743,6 +745,7 @@ class ChatService {
|
|||||||
let quotedSender: string | undefined
|
let quotedSender: string | undefined
|
||||||
let imageMd5: string | undefined
|
let imageMd5: string | undefined
|
||||||
let imageDatName: string | undefined
|
let imageDatName: string | undefined
|
||||||
|
let videoMd5: string | undefined
|
||||||
let aesKey: string | undefined
|
let aesKey: string | undefined
|
||||||
let encrypVer: number | undefined
|
let encrypVer: number | undefined
|
||||||
let cdnThumbUrl: string | undefined
|
let cdnThumbUrl: string | undefined
|
||||||
@@ -759,6 +762,9 @@ class ChatService {
|
|||||||
encrypVer = imageInfo.encrypVer
|
encrypVer = imageInfo.encrypVer
|
||||||
cdnThumbUrl = imageInfo.cdnThumbUrl
|
cdnThumbUrl = imageInfo.cdnThumbUrl
|
||||||
imageDatName = this.parseImageDatNameFromRow(row)
|
imageDatName = this.parseImageDatNameFromRow(row)
|
||||||
|
} else if (localType === 43 && content) {
|
||||||
|
// 视频消息
|
||||||
|
videoMd5 = this.parseVideoMd5(content)
|
||||||
} else if (localType === 34 && content) {
|
} else if (localType === 34 && content) {
|
||||||
voiceDurationSeconds = this.parseVoiceDurationSeconds(content)
|
voiceDurationSeconds = this.parseVoiceDurationSeconds(content)
|
||||||
} else if (localType === 244813135921 || (content && content.includes('<type>57</type>'))) {
|
} else if (localType === 244813135921 || (content && content.includes('<type>57</type>'))) {
|
||||||
@@ -783,6 +789,7 @@ class ChatService {
|
|||||||
quotedSender,
|
quotedSender,
|
||||||
imageMd5,
|
imageMd5,
|
||||||
imageDatName,
|
imageDatName,
|
||||||
|
videoMd5,
|
||||||
voiceDurationSeconds,
|
voiceDurationSeconds,
|
||||||
aesKey,
|
aesKey,
|
||||||
encrypVer,
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 解析通话消息
|
* 解析通话消息
|
||||||
* 格式: <voipmsg type="VoIPBubbleMsg"><VoIPBubbleMsg><msg><![CDATA[...]]></msg><room_type>0/1</room_type>...</VoIPBubbleMsg></voipmsg>
|
* 格式: <voipmsg type="VoIPBubbleMsg"><VoIPBubbleMsg><msg><![CDATA[...]]></msg><room_type>0/1</room_type>...</VoIPBubbleMsg></voipmsg>
|
||||||
@@ -1446,13 +1473,10 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private extractXmlAttribute(xml: string, tagName: string, attrName: string): string {
|
private extractXmlAttribute(xml: string, tagName: string, attrName: string): string {
|
||||||
const tagRegex = new RegExp(`<${tagName}[^>]*>`, 'i')
|
// 匹配 <tagName ... attrName="value" ... /> 或 <tagName ... attrName="value" ...>
|
||||||
const tagMatch = tagRegex.exec(xml)
|
const regex = new RegExp(`<${tagName}[^>]*\\s${attrName}\\s*=\\s*['"]([^'"]*)['"']`, 'i')
|
||||||
if (!tagMatch) return ''
|
const match = regex.exec(xml)
|
||||||
|
return match ? match[1] : ''
|
||||||
const attrRegex = new RegExp(`${attrName}\\s*=\\s*['"]([^'"]*)['"]`, 'i')
|
|
||||||
const attrMatch = attrRegex.exec(tagMatch[0])
|
|
||||||
return attrMatch ? attrMatch[1] : ''
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private cleanSystemMessage(content: string): string {
|
private cleanSystemMessage(content: string): string {
|
||||||
@@ -2918,6 +2942,7 @@ class ChatService {
|
|||||||
isSend: this.getRowInt(row, ['computed_is_send', 'computedIsSend', 'is_send', 'isSend', 'WCDB_CT_is_send'], 0),
|
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,
|
senderUsername: this.getRowField(row, ['sender_username', 'senderUsername', 'sender', 'WCDB_CT_sender_username']) || null,
|
||||||
rawContent: rawContent,
|
rawContent: rawContent,
|
||||||
|
content: rawContent, // 添加原始内容供视频MD5解析使用
|
||||||
parsedContent: this.parseMessageContent(rawContent, this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0))
|
parsedContent: this.parseMessageContent(rawContent, this.getRowInt(row, ['local_type', 'localType', 'type', 'msg_type', 'msgType', 'WCDB_CT_local_type'], 0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
289
electron/services/videoService.ts
Normal file
289
electron/services/videoService.ts
Normal file
@@ -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<string | undefined> {
|
||||||
|
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<VideoInfo> {
|
||||||
|
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" 或 <md5>xxx</md5>
|
||||||
|
|
||||||
|
// 尝试从videomsg标签中提取md5
|
||||||
|
const videoMsgMatch = /<videomsg[^>]*\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 = /<md5>([a-fA-F0-9]+)<\/md5>/i.exec(content)
|
||||||
|
if (md5Match) {
|
||||||
|
console.log('[VideoService] Found MD5 via <md5> 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()
|
||||||
@@ -15,6 +15,7 @@ import GroupAnalyticsPage from './pages/GroupAnalyticsPage'
|
|||||||
import DataManagementPage from './pages/DataManagementPage'
|
import DataManagementPage from './pages/DataManagementPage'
|
||||||
import SettingsPage from './pages/SettingsPage'
|
import SettingsPage from './pages/SettingsPage'
|
||||||
import ExportPage from './pages/ExportPage'
|
import ExportPage from './pages/ExportPage'
|
||||||
|
import VideoWindow from './pages/VideoWindow'
|
||||||
|
|
||||||
import { useAppStore } from './stores/appStore'
|
import { useAppStore } from './stores/appStore'
|
||||||
import { themes, useThemeStore, type ThemeId } from './stores/themeStore'
|
import { themes, useThemeStore, type ThemeId } from './stores/themeStore'
|
||||||
@@ -29,6 +30,7 @@ function App() {
|
|||||||
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
|
const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore()
|
||||||
const isAgreementWindow = location.pathname === '/agreement-window'
|
const isAgreementWindow = location.pathname === '/agreement-window'
|
||||||
const isOnboardingWindow = location.pathname === '/onboarding-window'
|
const isOnboardingWindow = location.pathname === '/onboarding-window'
|
||||||
|
const isVideoPlayerWindow = location.pathname === '/video-player-window'
|
||||||
const [themeHydrated, setThemeHydrated] = useState(false)
|
const [themeHydrated, setThemeHydrated] = useState(false)
|
||||||
|
|
||||||
// 协议同意状态
|
// 协议同意状态
|
||||||
@@ -219,6 +221,11 @@ function App() {
|
|||||||
return <WelcomePage standalone />
|
return <WelcomePage standalone />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 独立视频播放窗口
|
||||||
|
if (isVideoPlayerWindow) {
|
||||||
|
return <VideoWindow />
|
||||||
|
}
|
||||||
|
|
||||||
// 主窗口 - 完整布局
|
// 主窗口 - 完整布局
|
||||||
return (
|
return (
|
||||||
<div className="app-container">
|
<div className="app-container">
|
||||||
|
|||||||
@@ -1957,3 +1957,84 @@
|
|||||||
height: 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1343,6 +1343,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
|||||||
const isSystem = isSystemMessage(message.localType)
|
const isSystem = isSystemMessage(message.localType)
|
||||||
const isEmoji = message.localType === 47
|
const isEmoji = message.localType === 47
|
||||||
const isImage = message.localType === 3
|
const isImage = message.localType === 3
|
||||||
|
const isVideo = message.localType === 43
|
||||||
const isVoice = message.localType === 34
|
const isVoice = message.localType === 34
|
||||||
const isSent = message.isSend === 1
|
const isSent = message.isSend === 1
|
||||||
const [senderAvatarUrl, setSenderAvatarUrl] = useState<string | undefined>(undefined)
|
const [senderAvatarUrl, setSenderAvatarUrl] = useState<string | undefined>(undefined)
|
||||||
@@ -1371,6 +1372,56 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
|||||||
const [voiceWaveform, setVoiceWaveform] = useState<number[]>([])
|
const [voiceWaveform, setVoiceWaveform] = useState<number[]>([])
|
||||||
const voiceAutoDecryptTriggered = useRef(false)
|
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<HTMLDivElement>(null)
|
||||||
|
const [isVideoVisible, setIsVideoVisible] = useState(false)
|
||||||
|
const [videoMd5, setVideoMd5] = useState<string | null>(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(() => {
|
useEffect(() => {
|
||||||
const loadConfig = async () => {
|
const loadConfig = async () => {
|
||||||
@@ -1838,6 +1889,62 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, o
|
|||||||
}
|
}
|
||||||
}, [isVoice, message.localId, requestVoiceTranscript])
|
}, [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)
|
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 (
|
||||||
|
<div className="video-placeholder" ref={videoContainerRef}>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polygon points="23 7 16 12 23 17 23 7"></polygon>
|
||||||
|
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载中
|
||||||
|
if (videoLoading) {
|
||||||
|
return (
|
||||||
|
<div className="video-loading" ref={videoContainerRef}>
|
||||||
|
<Loader2 size={20} className="spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 视频不存在
|
||||||
|
if (!videoInfo?.exists || !videoInfo.videoUrl) {
|
||||||
|
return (
|
||||||
|
<div className="video-unavailable" ref={videoContainerRef}>
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polygon points="23 7 16 12 23 17 23 7"></polygon>
|
||||||
|
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
|
||||||
|
</svg>
|
||||||
|
<span>视频不可用</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认显示缩略图,点击打开独立播放窗口
|
||||||
|
const thumbSrc = videoInfo.thumbUrl || videoInfo.coverUrl
|
||||||
|
return (
|
||||||
|
<div className="video-thumb-wrapper" ref={videoContainerRef} onClick={handlePlayVideo}>
|
||||||
|
{thumbSrc ? (
|
||||||
|
<img src={thumbSrc} alt="视频缩略图" className="video-thumb" />
|
||||||
|
) : (
|
||||||
|
<div className="video-thumb-placeholder">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<polygon points="23 7 16 12 23 17 23 7"></polygon>
|
||||||
|
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="video-play-button">
|
||||||
|
<Play size={32} fill="white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (isVoice) {
|
if (isVoice) {
|
||||||
const durationText = message.voiceDurationSeconds ? `${message.voiceDurationSeconds}"` : ''
|
const durationText = message.voiceDurationSeconds ? `${message.voiceDurationSeconds}"` : ''
|
||||||
const handleToggle = async () => {
|
const handleToggle = async () => {
|
||||||
|
|||||||
216
src/pages/VideoWindow.scss
Normal file
216
src/pages/VideoWindow.scss
Normal file
@@ -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); }
|
||||||
|
}
|
||||||
199
src/pages/VideoWindow.tsx
Normal file
199
src/pages/VideoWindow.tsx
Normal file
@@ -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<string | null>(null)
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
|
const progressRef = useRef<HTMLDivElement>(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<HTMLDivElement>) => {
|
||||||
|
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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="video-window-empty">
|
||||||
|
<span>无效的视频路径</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = duration > 0 ? (currentTime / duration) * 100 : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="video-window-container">
|
||||||
|
<div className="title-bar">
|
||||||
|
<div className="window-drag-area"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="video-viewport" onClick={togglePlay}>
|
||||||
|
{isLoading && (
|
||||||
|
<div className="video-loading-overlay">
|
||||||
|
<div className="spinner"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="video-error-overlay">
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
src={videoPath}
|
||||||
|
onLoadedMetadata={(e) => {
|
||||||
|
const video = e.currentTarget
|
||||||
|
setDuration(video.duration)
|
||||||
|
setIsLoading(false)
|
||||||
|
// 根据视频尺寸调整窗口大小
|
||||||
|
if (video.videoWidth && video.videoHeight) {
|
||||||
|
window.electronAPI.window.resizeToFitVideo(video.videoWidth, video.videoHeight)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onTimeUpdate={(e) => setCurrentTime(e.currentTarget.currentTime)}
|
||||||
|
onPlay={() => setIsPlaying(true)}
|
||||||
|
onPause={() => setIsPlaying(false)}
|
||||||
|
onEnded={() => setIsPlaying(false)}
|
||||||
|
onError={() => {
|
||||||
|
setError('视频加载失败')
|
||||||
|
setIsLoading(false)
|
||||||
|
}}
|
||||||
|
onWaiting={() => setIsLoading(true)}
|
||||||
|
onCanPlay={() => setIsLoading(false)}
|
||||||
|
autoPlay
|
||||||
|
/>
|
||||||
|
{!isPlaying && !isLoading && !error && (
|
||||||
|
<div className="play-overlay">
|
||||||
|
<Play size={64} fill="white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="video-controls" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div
|
||||||
|
className="progress-bar"
|
||||||
|
ref={progressRef}
|
||||||
|
onClick={handleProgressClick}
|
||||||
|
>
|
||||||
|
<div className="progress-track">
|
||||||
|
<div className="progress-fill" style={{ width: `${progress}%` }}></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="controls-row">
|
||||||
|
<div className="controls-left">
|
||||||
|
<button onClick={togglePlay} title={isPlaying ? '暂停 (空格)' : '播放 (空格)'}>
|
||||||
|
{isPlaying ? <Pause size={18} /> : <Play size={18} />}
|
||||||
|
</button>
|
||||||
|
<button onClick={handleReplay} title="重新播放">
|
||||||
|
<RotateCcw size={16} />
|
||||||
|
</button>
|
||||||
|
<span className="time-display">
|
||||||
|
{formatTime(currentTime)} / {formatTime(duration)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="controls-right">
|
||||||
|
<div className="volume-control">
|
||||||
|
<button onClick={toggleMute} title={isMuted ? '取消静音 (M)' : '静音 (M)'}>
|
||||||
|
{isMuted || volume === 0 ? <VolumeX size={16} /> : <Volume2 size={16} />}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.1"
|
||||||
|
value={isMuted ? 0 : volume}
|
||||||
|
onChange={handleVolumeChange}
|
||||||
|
className="volume-slider"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
src/types/electron.d.ts
vendored
17
src/types/electron.d.ts
vendored
@@ -9,6 +9,8 @@ export interface ElectronAPI {
|
|||||||
completeOnboarding: () => Promise<boolean>
|
completeOnboarding: () => Promise<boolean>
|
||||||
openOnboardingWindow: () => Promise<boolean>
|
openOnboardingWindow: () => Promise<boolean>
|
||||||
setTitleBarOverlay: (options: { symbolColor: string }) => void
|
setTitleBarOverlay: (options: { symbolColor: string }) => void
|
||||||
|
openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => Promise<void>
|
||||||
|
resizeToFitVideo: (videoWidth: number, videoHeight: number) => Promise<void>
|
||||||
}
|
}
|
||||||
config: {
|
config: {
|
||||||
get: (key: string) => Promise<unknown>
|
get: (key: string) => Promise<unknown>
|
||||||
@@ -107,6 +109,21 @@ export interface ElectronAPI {
|
|||||||
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
|
onUpdateAvailable: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => () => void
|
||||||
onCacheResolved: (callback: (payload: { cacheKey: string; imageMd5?: string; imageDatName?: string; localPath: 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: {
|
analytics: {
|
||||||
getOverallStatistics: (force?: boolean) => Promise<{
|
getOverallStatistics: (force?: boolean) => Promise<{
|
||||||
success: boolean
|
success: boolean
|
||||||
|
|||||||
@@ -33,11 +33,13 @@ export interface Message {
|
|||||||
isSend: number | null
|
isSend: number | null
|
||||||
senderUsername: string | null
|
senderUsername: string | null
|
||||||
parsedContent: string
|
parsedContent: string
|
||||||
|
content?: string // 原始消息内容(XML)
|
||||||
imageMd5?: string
|
imageMd5?: string
|
||||||
imageDatName?: string
|
imageDatName?: string
|
||||||
emojiCdnUrl?: string
|
emojiCdnUrl?: string
|
||||||
emojiMd5?: string
|
emojiMd5?: string
|
||||||
voiceDurationSeconds?: number
|
voiceDurationSeconds?: number
|
||||||
|
videoMd5?: string
|
||||||
// 引用消息
|
// 引用消息
|
||||||
quotedContent?: string
|
quotedContent?: string
|
||||||
quotedSender?: string
|
quotedSender?: string
|
||||||
|
|||||||
Reference in New Issue
Block a user