mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-18 03:57:02 +08:00
feat: 支持微信视频消息解析与播放
支持视频 XML 解析、本地文件映射与拖动播放。 修复源码乱码注释并统一 UTF-8 编码。
This commit is contained in:
+107
-18
@@ -1,4 +1,4 @@
|
||||
import './preload-env'
|
||||
import './preload-env'
|
||||
import {
|
||||
app,
|
||||
shell,
|
||||
@@ -8,10 +8,12 @@ import {
|
||||
clipboard,
|
||||
Menu,
|
||||
Tray,
|
||||
dialog
|
||||
dialog,
|
||||
protocol
|
||||
} from 'electron'
|
||||
import { join } from 'path'
|
||||
import { existsSync } from 'fs'
|
||||
import { existsSync, promises as fsPromises } from 'fs'
|
||||
import { extname } from 'path'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
import icon from '../../resources/icon.png?asset'
|
||||
import { WechatDb } from './wechat-db'
|
||||
@@ -72,6 +74,7 @@ import { agentHubService } from './services/agent-hub-service'
|
||||
import { appLogger } from './app-logger'
|
||||
import type { AppLogEntry } from '../shared/app-log'
|
||||
import { configureRecallArchive, RecallArchiveMonitor } from './services/recall-archive-service'
|
||||
import { VideoAssetService } from './video-asset-service'
|
||||
|
||||
// electron-vite can close the child's stdout/stderr after spawning Electron.
|
||||
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
|
||||
@@ -81,6 +84,7 @@ installSafeConsole()
|
||||
let voiceService: VoiceService | null = null
|
||||
let imageDecryptService: ImageDecryptService | null = null
|
||||
let stickerService: StickerService | null = null
|
||||
let videoAssetService: VideoAssetService | null = null
|
||||
const databaseKeyStore = new DatabaseKeyStore()
|
||||
const imageKeyConfigService = new ImageKeyConfigService()
|
||||
const aiProviderService = new AIProviderService()
|
||||
@@ -92,6 +96,13 @@ let recallArchiveMonitor: RecallArchiveMonitor | null = null
|
||||
const packagedIconPath = join(process.resourcesPath, 'resources', 'icon.png')
|
||||
const appIconPath = existsSync(packagedIconPath) ? packagedIconPath : icon
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: 'wxe-media',
|
||||
privileges: { secure: true, standard: true, stream: true, supportFetchAPI: true }
|
||||
}
|
||||
])
|
||||
|
||||
// WCDB's Windows runtime checks the host application name during wcdb_init.
|
||||
// Mirroring WeFlow's name unblocks the -1006 init failure on Windows.
|
||||
app.setName(process.platform === 'win32' ? 'WeFlow' : 'WechatExplorer')
|
||||
@@ -109,8 +120,65 @@ function getConfiguredImageKeys(): { xorKey: string; aesKey: string } {
|
||||
}
|
||||
}
|
||||
|
||||
async function createLocalMediaResponse(request: Request, filePath: string): Promise<Response> {
|
||||
const { size } = await fsPromises.stat(filePath)
|
||||
const mimeType = extname(filePath).toLowerCase() === '.mp4' ? 'video/mp4' : 'image/jpeg'
|
||||
const commonHeaders = {
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Type': mimeType,
|
||||
'Cache-Control': 'private, max-age=300'
|
||||
}
|
||||
const range = request.headers.get('range')
|
||||
|
||||
if (!range) {
|
||||
const body =
|
||||
request.method === 'HEAD' ? null : Uint8Array.from(await fsPromises.readFile(filePath))
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { ...commonHeaders, 'Content-Length': String(size) }
|
||||
})
|
||||
}
|
||||
|
||||
const match = /^bytes=(\d+)-(\d*)$/i.exec(range.trim())
|
||||
if (!match) {
|
||||
return new Response(null, {
|
||||
status: 416,
|
||||
headers: { ...commonHeaders, 'Content-Range': `bytes */${size}` }
|
||||
})
|
||||
}
|
||||
const start = Number(match[1])
|
||||
const requestedEnd = match[2] ? Number(match[2]) : size - 1
|
||||
const end = Math.min(requestedEnd, size - 1)
|
||||
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start > end || start >= size) {
|
||||
return new Response(null, {
|
||||
status: 416,
|
||||
headers: { ...commonHeaders, 'Content-Range': `bytes */${size}` }
|
||||
})
|
||||
}
|
||||
|
||||
const length = end - start + 1
|
||||
let body: Buffer | null = null
|
||||
if (request.method !== 'HEAD') {
|
||||
const handle = await fsPromises.open(filePath, 'r')
|
||||
try {
|
||||
body = Buffer.allocUnsafe(length)
|
||||
await handle.read(body, 0, length, start)
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
return new Response(body ? Uint8Array.from(body) : null, {
|
||||
status: 206,
|
||||
headers: {
|
||||
...commonHeaders,
|
||||
'Content-Length': String(length),
|
||||
'Content-Range': `bytes ${start}-${end}/${size}`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function createWindow(): void {
|
||||
// 鍒涘缓娴忚鍣ㄧ獥鍙?
|
||||
// 创建浏览器窗口
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1400,
|
||||
height: 800,
|
||||
@@ -132,8 +200,8 @@ function createWindow(): void {
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
// 鍩轰簬 electron-vite cli 鐨勬覆鏌撳櫒 HMR
|
||||
// 鍔犺浇寮€鍙戠幆澧冪殑杩滅▼ URL 鎴栫敓浜х幆澧冪殑鏈湴 html 鏂囦欢
|
||||
// 基于 electron-vite CLI 的渲染器热更新
|
||||
// 加载开发环境的远程 URL,或生产环境的本地 HTML 文件
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||
} else {
|
||||
@@ -141,9 +209,20 @@ function createWindow(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// 褰?Electron 瀹屾垚鍒濆鍖栧苟鍑嗗濂藉垱寤烘祻瑙堝櫒绐楀彛鏃讹紝灏嗚皟鐢ㄦ鏂规硶
|
||||
// 鏌愪簺 API 鍙兘鍦ㄦ浜嬩欢鍙戠敓鍚庝娇鐢?
|
||||
// Electron 初始化完成并准备创建浏览器窗口后,将调用此方法
|
||||
// 某些 API 只能在此事件发生后使用
|
||||
app.whenReady().then(async () => {
|
||||
protocol.handle('wxe-media', async (request) => {
|
||||
const token = new URL(request.url).pathname.replace(/^\/+/, '')
|
||||
const filePath = videoAssetService?.pathForToken(token)
|
||||
if (!filePath) return new Response('Not found', { status: 404 })
|
||||
try {
|
||||
return await createLocalMediaResponse(request, filePath)
|
||||
} catch (error) {
|
||||
console.warn('[Video] local media request failed:', error)
|
||||
return new Response('Media unavailable', { status: 500 })
|
||||
}
|
||||
})
|
||||
console.log(`WechatExplorer main build: ${BUILD_MARK}`)
|
||||
appLogger.write({
|
||||
level: 'info',
|
||||
@@ -181,14 +260,14 @@ app.whenReady().then(async () => {
|
||||
console.error('[WCDB4] bootstrap failed at whenReady top:', bootstrapError)
|
||||
}
|
||||
|
||||
// 涓虹獥鍙h缃簲鐢ㄧ▼搴忕敤鎴锋ā鍨?ID
|
||||
// 设置应用程序用户模型 ID
|
||||
electronApp.setAppUserModelId('com.wechatexplorer.app')
|
||||
|
||||
if (process.platform === 'darwin') app.dock?.setIcon(appIconPath)
|
||||
|
||||
// 鍦ㄥ紑鍙戠幆澧冧腑榛樿鎸?F12 鎵撳紑鎴栧叧闂?DevTools
|
||||
// 鍦ㄧ敓浜х幆澧冧腑蹇界暐 CommandOrControl + R
|
||||
// 鍙傝 https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
||||
// 开发环境中默认使用 F12 打开或关闭 DevTools
|
||||
// 生产环境中忽略 CommandOrControl + R
|
||||
// 参见 https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
})
|
||||
@@ -254,6 +333,7 @@ app.whenReady().then(async () => {
|
||||
}, 0)
|
||||
voiceService = new VoiceService(wcdb4Client)
|
||||
stickerService = new StickerService(wcdb4Client)
|
||||
videoAssetService = new VideoAssetService(wcdb4Client)
|
||||
const monitoring = wcdb4Client.startMonitor((type, json) => {
|
||||
wcdb4Client.invalidateSessionCache()
|
||||
recallArchiveMonitor?.handleDatabaseChange(json)
|
||||
@@ -657,6 +737,15 @@ app.whenReady().then(async () => {
|
||||
return stickerService.resolveSticker(cdnUrl, md5)
|
||||
})
|
||||
|
||||
ipcMain.handle('db:getVideo', async (_, hashes: string[]) => {
|
||||
if (!videoAssetService) {
|
||||
const client = chat.getChatDb()?.getWcdb4Client()
|
||||
if (!client) return { success: false, error: '数据库尚未连接' }
|
||||
videoAssetService = new VideoAssetService(client)
|
||||
}
|
||||
return videoAssetService.resolve(Array.isArray(hashes) ? hashes : [])
|
||||
})
|
||||
|
||||
// -------- Settings & API service --------
|
||||
|
||||
ipcMain.handle('settings:get', () => ({
|
||||
@@ -786,7 +875,7 @@ app.whenReady().then(async () => {
|
||||
|
||||
createWindow()
|
||||
|
||||
// 鍚姩鏈湴 HTTP API(鏍规嵁 settings.apiEnabled 鎺у埗)
|
||||
// 启动本地 HTTP API(由 settings.apiEnabled 控制)
|
||||
const settings = loadSettings()
|
||||
if (settings.apiEnabled) {
|
||||
await apiServer.start(settings.apiHost, settings.apiPort)
|
||||
@@ -800,15 +889,15 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
|
||||
app.on('activate', function () {
|
||||
// 鍦?macOS 涓婏紝褰撶偣鍑?dock 鍥炬爣涓旀病鏈夊叾浠栫獥鍙f墦寮€鏃讹紝
|
||||
// 閫氬父浼氬湪搴旂敤绋嬪簭涓噸鏂板垱寤轰竴涓獥鍙c€?
|
||||
// 在 macOS 上点击 Dock 图标且没有其他窗口打开时,
|
||||
// 通常会在应用程序中重新创建一个窗口。
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
})
|
||||
|
||||
// 褰撴墍鏈夌獥鍙e叧闂椂閫€鍑猴紝闄や簡 macOS銆傚湪閭i噷锛?
|
||||
// 搴旂敤绋嬪簭鍙婂叾鑿滃崟鏍忛€氬父浼氫繚鎸佹椿鍔ㄧ姸鎬侊紝鐩村埌鐢ㄦ埛
|
||||
// 鏄惧紡浣跨敤 Cmd + Q 閫€鍑恒€?
|
||||
// 除 macOS 外,所有窗口关闭时退出应用。在 macOS 上,
|
||||
// 应用程序及其菜单栏通常会保持活动状态,直到用户
|
||||
// 明确使用 Cmd + Q 退出。
|
||||
app.on('window-all-closed', () => {
|
||||
if (TRAY_MODE) return
|
||||
if (process.platform !== 'darwin') {
|
||||
|
||||
@@ -24,6 +24,15 @@ type ImageContent = {
|
||||
aeskey?: string
|
||||
encrypVer?: number
|
||||
}
|
||||
type VideoContent = {
|
||||
type: 'video'
|
||||
md5?: string
|
||||
newMd5?: string
|
||||
rawMd5?: string
|
||||
duration?: number
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
type StickerContent = {
|
||||
type: 'sticker'
|
||||
md5?: string
|
||||
@@ -64,6 +73,7 @@ export type ParsedContent =
|
||||
| ShareContent
|
||||
| VoipContent
|
||||
| ImageContent
|
||||
| VideoContent
|
||||
| StickerContent
|
||||
| QuoteContent
|
||||
| SystemContent
|
||||
@@ -81,6 +91,8 @@ export function parseMessageContent(content: string, messageType: number): Parse
|
||||
return parseImageMessage(normalized)
|
||||
case 42:
|
||||
return parseCardMessage(normalized)
|
||||
case 43:
|
||||
return parseVideoMessage(normalized)
|
||||
case 47:
|
||||
return parseStickerMessage(normalized)
|
||||
case 48:
|
||||
@@ -97,6 +109,19 @@ export function parseMessageContent(content: string, messageType: number): Parse
|
||||
}
|
||||
}
|
||||
|
||||
function parseVideoMessage(content: string): ParsedContent {
|
||||
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
|
||||
const md5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'md5'))
|
||||
const newMd5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'newmd5'))
|
||||
const rawMd5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'rawmd5'))
|
||||
if (!md5 && !newMd5 && !rawMd5) return { type: 'unknown', raw: content }
|
||||
|
||||
const duration = Number(extractXmlAttribute(decoded, 'videomsg', 'playlength')) || undefined
|
||||
const width = Number(extractXmlAttribute(decoded, 'videomsg', 'cdnthumbwidth')) || undefined
|
||||
const height = Number(extractXmlAttribute(decoded, 'videomsg', 'cdnthumbheight')) || undefined
|
||||
return { type: 'video', md5, newMd5, rawMd5, duration, width, height }
|
||||
}
|
||||
|
||||
function parseSystemMessage(content: string): ParsedContent {
|
||||
const stripped = stripChatroomPrefix(content)
|
||||
const decoded = decodeXmlEntities(stripped)
|
||||
@@ -498,7 +523,10 @@ function extractXmlValue(xml: string, tagName: string): string {
|
||||
}
|
||||
|
||||
function extractXmlAttribute(xml: string, tagName: string, attrName: string): string {
|
||||
const pattern = new RegExp(`<${tagName}[^>]*${attrName}=["']([^"']*)["']`, 'i')
|
||||
const pattern = new RegExp(
|
||||
`<${tagName}\\b[^>]*?(?:\\s|^)${attrName}\\s*=\\s*["']([^"']*)["']`,
|
||||
'i'
|
||||
)
|
||||
const match = xml.match(pattern)
|
||||
return match ? match[1].trim() : ''
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ function listSourceMessages(
|
||||
/<appmsg\b|<refermsg\b|<appmsg\b|<refermsg\b/i.test(content)
|
||||
? 49
|
||||
: msgType
|
||||
if ([3, 42, 47, 48, 49, 50, 10000, 10002].includes(inferredMsgType)) {
|
||||
if ([3, 42, 43, 47, 48, 49, 50, 10000, 10002].includes(inferredMsgType)) {
|
||||
try {
|
||||
const parsed =
|
||||
inferredMsgType === 47
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import crypto from 'crypto'
|
||||
import type { Wcdb4Client } from './wcdb4-client'
|
||||
|
||||
type VideoAsset = {
|
||||
filePath: string
|
||||
posterPath?: string
|
||||
}
|
||||
|
||||
export class VideoAssetService {
|
||||
private readonly urlTokens = new Map<string, string>()
|
||||
private index: Map<string, VideoAsset> | null = null
|
||||
|
||||
constructor(private readonly client: Wcdb4Client) {}
|
||||
|
||||
resolve(hashes: string[]): { success: boolean; url?: string; poster?: string; error?: string } {
|
||||
const candidates = Array.from(
|
||||
new Set(
|
||||
hashes
|
||||
.map((value) =>
|
||||
String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
)
|
||||
.filter((value) => /^[a-f0-9]{32}$/.test(value))
|
||||
)
|
||||
)
|
||||
if (candidates.length === 0) return { success: false, error: '视频标识为空' }
|
||||
|
||||
const hardlinkDb = path.join(
|
||||
this.client.getAccountRoot(),
|
||||
'db_storage',
|
||||
'hardlink',
|
||||
'hardlink.db'
|
||||
)
|
||||
const lookupKeys = [...candidates]
|
||||
if (fs.existsSync(hardlinkDb)) {
|
||||
for (const hash of candidates) {
|
||||
const resolved = this.client.resolveVideoHardlink(hash, hardlinkDb)?.resolved_md5
|
||||
if (resolved) lookupKeys.unshift(String(resolved).trim().toLowerCase())
|
||||
}
|
||||
}
|
||||
|
||||
const index = this.getIndex()
|
||||
for (const key of lookupKeys) {
|
||||
const asset = index.get(key) || index.get(`${key}_raw`)
|
||||
if (!asset) continue
|
||||
return {
|
||||
success: true,
|
||||
url: this.createUrl(asset.filePath),
|
||||
poster: asset.posterPath ? this.createUrl(asset.posterPath) : undefined
|
||||
}
|
||||
}
|
||||
return { success: false, error: '本地未找到该视频文件' }
|
||||
}
|
||||
|
||||
pathForToken(token: string): string | undefined {
|
||||
const filePath = this.urlTokens.get(token)
|
||||
if (!filePath || !fs.existsSync(filePath)) return undefined
|
||||
return filePath
|
||||
}
|
||||
|
||||
private createUrl(filePath: string): string {
|
||||
const token = crypto.randomBytes(18).toString('hex')
|
||||
this.urlTokens.set(token, filePath)
|
||||
if (this.urlTokens.size > 500) {
|
||||
const first = this.urlTokens.keys().next().value
|
||||
if (first) this.urlTokens.delete(first)
|
||||
}
|
||||
return `wxe-media://local/${token}`
|
||||
}
|
||||
|
||||
private getIndex(): Map<string, VideoAsset> {
|
||||
if (this.index) return this.index
|
||||
const result = new Map<string, VideoAsset>()
|
||||
const root = path.join(this.client.getAccountRoot(), 'msg', 'video')
|
||||
if (!fs.existsSync(root)) {
|
||||
this.index = result
|
||||
return result
|
||||
}
|
||||
|
||||
for (const month of fs.readdirSync(root)) {
|
||||
const monthPath = path.join(root, month)
|
||||
if (!fs.statSync(monthPath).isDirectory()) continue
|
||||
for (const name of fs.readdirSync(monthPath)) {
|
||||
const match = /^([a-f0-9]{32})(?:(_raw))?\.(mp4|jpg)$/i.exec(name)
|
||||
if (!match) continue
|
||||
const key = `${match[1].toLowerCase()}${match[2] || ''}`
|
||||
const fullPath = path.join(monthPath, name)
|
||||
const existing = result.get(key) || { filePath: '' }
|
||||
if (match[3].toLowerCase() === 'mp4') existing.filePath = fullPath
|
||||
else if (!existing.posterPath) existing.posterPath = fullPath
|
||||
result.set(key, existing)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, asset] of result) {
|
||||
if (!asset.filePath) result.delete(key)
|
||||
}
|
||||
this.index = result
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,11 @@ export interface Wcdb4ImageHardlink {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface Wcdb4VideoHardlink {
|
||||
resolved_md5?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type KoffiModule = {
|
||||
load: (libraryPath: string) => KoffiLibrary
|
||||
decode: (ptr: unknown, type: string, length: number) => string
|
||||
@@ -223,6 +228,9 @@ export class Wcdb4Client {
|
||||
private wcdbResolveImageHardlink:
|
||||
| ((handle: number, md5: string, accountDir: string, outJson: WcdbVoidOut) => number)
|
||||
| null = null
|
||||
private wcdbResolveVideoHardlink:
|
||||
| ((handle: number, md5: string, dbPath: string, outJson: WcdbVoidOut) => number)
|
||||
| null = null
|
||||
private wcdbGetEmoticonCdnUrl:
|
||||
| ((handle: number, dbPath: string, md5: string, outUrl: WcdbVoidOut) => number)
|
||||
| null = null
|
||||
@@ -1239,6 +1247,23 @@ export class Wcdb4Client {
|
||||
}
|
||||
}
|
||||
|
||||
resolveVideoHardlink(md5: string, dbPath: string): Wcdb4VideoHardlink | null {
|
||||
if (!this.wcdbResolveVideoHardlink) return null
|
||||
const normalizedMd5 = String(md5 || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (!/^[a-f0-9]{32}$/.test(normalizedMd5) || !dbPath) return null
|
||||
|
||||
try {
|
||||
return this.callJson<Wcdb4VideoHardlink>((handle, outJson) =>
|
||||
this.wcdbResolveVideoHardlink!(handle, normalizedMd5, dbPath, outJson)
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn('[WCDB4] resolve video hardlink failed:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
resolveEmoticonCdnUrl(md5: string): string | undefined {
|
||||
if (!this.wcdbGetEmoticonCdnUrl) {
|
||||
console.warn(`[WCDB4] wcdb_get_emoticon_cdn_url unavailable for md5=${md5}`)
|
||||
@@ -1452,6 +1477,14 @@ export class Wcdb4Client {
|
||||
this.wcdbResolveImageHardlink = null
|
||||
}
|
||||
|
||||
try {
|
||||
this.wcdbResolveVideoHardlink = lib.func(
|
||||
'int32 wcdb_resolve_video_hardlink_md5(int64 handle, const char* md5, const char* dbPath, _Out_ void** outJson)'
|
||||
) as (handle: number, md5: string, dbPath: string, outJson: WcdbVoidOut) => number
|
||||
} catch {
|
||||
this.wcdbResolveVideoHardlink = null
|
||||
}
|
||||
|
||||
try {
|
||||
this.wcdbGetEmoticonCdnUrl = lib.func(
|
||||
'int32 wcdb_get_emoticon_cdn_url(int64 handle, const char* dbPath, const char* md5, _Out_ void** outUrl)'
|
||||
|
||||
Vendored
+12
@@ -48,6 +48,15 @@ export type ParsedContent =
|
||||
| { type: 'share'; title: string; des?: string; url: string; appname?: string; type?: string }
|
||||
| { type: 'voip'; duration?: number; status: string; roomType?: number }
|
||||
| { type: 'image'; md5?: string; datName?: string; aeskey?: string; encrypVer?: number }
|
||||
| {
|
||||
type: 'video'
|
||||
md5?: string
|
||||
newMd5?: string
|
||||
rawMd5?: string
|
||||
duration?: number
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
| {
|
||||
type: 'sticker'
|
||||
md5?: string
|
||||
@@ -151,6 +160,9 @@ declare global {
|
||||
isThumb?: boolean
|
||||
filePath?: string
|
||||
}>
|
||||
getVideo: (
|
||||
hashes: string[]
|
||||
) => Promise<{ success: boolean; url?: string; poster?: string; error?: string }>
|
||||
getSticker: (
|
||||
cdnUrl?: string,
|
||||
md5?: string
|
||||
|
||||
@@ -59,6 +59,7 @@ const api = {
|
||||
sessionId?: string,
|
||||
options?: { force?: boolean }
|
||||
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
|
||||
getVideo: (hashes: string[]) => ipcRenderer.invoke('db:getVideo', hashes),
|
||||
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
|
||||
exportGroupReport: (request: GroupReportExportRequest) =>
|
||||
ipcRenderer.invoke('report:export', request),
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<!-- https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP -->
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; media-src 'self' blob: data:;"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: wxe-media:; media-src 'self' blob: data: wxe-media:;"
|
||||
/>
|
||||
</head>
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { Sidebar } from './components/Sidebar'
|
||||
import ChatWindow from './components/ChatWindow'
|
||||
import { AppShell } from './components/layout/AppShell'
|
||||
|
||||
@@ -1375,6 +1375,54 @@ body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.video-message-bubble {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: #101412;
|
||||
border-color: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.wechat-message-row.mine .video-message-bubble {
|
||||
background: #101412;
|
||||
}
|
||||
|
||||
.video-message {
|
||||
position: relative;
|
||||
width: min(320px, 48vw);
|
||||
min-height: 140px;
|
||||
background: #101412;
|
||||
}
|
||||
|
||||
.video-content {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-height: 360px;
|
||||
object-fit: contain;
|
||||
background: #101412;
|
||||
}
|
||||
|
||||
.video-duration {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 8px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 8px;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
font-size: 11px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.video-placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: min(260px, 44vw);
|
||||
min-height: 120px;
|
||||
padding: 16px;
|
||||
color: rgba(255, 255, 255, 0.78);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.image-bubble {
|
||||
position: relative;
|
||||
max-width: min(260px, 46vw);
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
interface VideoBubbleProps {
|
||||
md5?: string
|
||||
newMd5?: string
|
||||
rawMd5?: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export function VideoBubble({
|
||||
md5,
|
||||
newMd5,
|
||||
rawMd5,
|
||||
duration
|
||||
}: VideoBubbleProps): React.ReactElement {
|
||||
const hashes = useMemo(
|
||||
() => [rawMd5, newMd5, md5].filter((value): value is string => Boolean(value)),
|
||||
[md5, newMd5, rawMd5]
|
||||
)
|
||||
const [media, setMedia] = useState<{ url?: string; poster?: string; error?: string }>({})
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
window.api
|
||||
.getVideo(hashes)
|
||||
.then((result) => {
|
||||
if (!cancelled) setMedia(result.success ? result : { error: result.error })
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) setMedia({ error: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [hashes])
|
||||
|
||||
if (!media.url) {
|
||||
return <div className="video-placeholder">{media.error || '视频加载中…'}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="video-message">
|
||||
<video
|
||||
className="video-content"
|
||||
src={media.url}
|
||||
poster={media.poster}
|
||||
controls
|
||||
preload="metadata"
|
||||
/>
|
||||
{duration ? <span className="video-duration">{duration} 秒</span> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Contact, Message } from '../../../../shared/types'
|
||||
import { ImageBubble } from '../ImageBubble'
|
||||
import { RichMessageBubble } from '../RichMessageBubble'
|
||||
import { VoicePlayer } from '../VoicePlayer'
|
||||
import { VideoBubble } from '../VideoBubble'
|
||||
import { renderWechatEmojiText } from '../../utils/wechatEmojiText'
|
||||
import { formatMessageTime } from './messageGrouping'
|
||||
|
||||
@@ -27,6 +28,7 @@ export function MessageBubble({
|
||||
}: MessageBubbleProps): React.ReactElement {
|
||||
const isVoice = message.type === '语音'
|
||||
const isImage = message.type === '图片'
|
||||
const isVideo = message.type === '视频'
|
||||
const isRichMedia = RICH_MESSAGE_TYPES.includes(message.type)
|
||||
const hoverTime = formatMessageTime(message)
|
||||
|
||||
@@ -35,7 +37,7 @@ export function MessageBubble({
|
||||
<div
|
||||
className={`message-bubble ${isVoice ? 'voice-bubble' : ''} ${
|
||||
isImage ? 'image-message-bubble' : ''
|
||||
}`}
|
||||
} ${isVideo ? 'video-message-bubble' : ''}`}
|
||||
>
|
||||
{isVoice && message.sessionId ? (
|
||||
<VoicePlayer
|
||||
@@ -50,6 +52,13 @@ export function MessageBubble({
|
||||
sessionId={message.sessionId}
|
||||
onImageClick={onImageClick}
|
||||
/>
|
||||
) : isVideo && message.contentData && message.contentData.type === 'video' ? (
|
||||
<VideoBubble
|
||||
md5={message.contentData.md5}
|
||||
newMd5={message.contentData.newMd5}
|
||||
rawMd5={message.contentData.rawMd5}
|
||||
duration={message.contentData.duration}
|
||||
/>
|
||||
) : isRichMedia && message.contentData ? (
|
||||
<RichMessageBubble contentData={message.contentData} />
|
||||
) : (
|
||||
|
||||
@@ -54,6 +54,15 @@ type ImageContent = {
|
||||
aeskey?: string
|
||||
encrypVer?: number
|
||||
}
|
||||
type VideoContent = {
|
||||
type: 'video'
|
||||
md5?: string
|
||||
newMd5?: string
|
||||
rawMd5?: string
|
||||
duration?: number
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
type StickerContent = {
|
||||
type: 'sticker'
|
||||
md5?: string
|
||||
@@ -94,6 +103,7 @@ export type ParsedContent =
|
||||
| ShareContent
|
||||
| VoipContent
|
||||
| ImageContent
|
||||
| VideoContent
|
||||
| StickerContent
|
||||
| QuoteContent
|
||||
| SystemContent
|
||||
|
||||
Reference in New Issue
Block a user