修复若干bug和ui

This commit is contained in:
2025-08-11 22:00:45 +08:00
parent aa59af1d66
commit abd52c712d
4 changed files with 295 additions and 53 deletions

View File

@@ -55,11 +55,11 @@ export function update(data) {
}
//获取话术
export function prologue(data) {
return getAxios({ url: 'api/common/prologue', data })
export function prologue() {
return getAxios({ url: 'api/common/prologue' })
}
//获取评论
export function comment(data) {
return getAxios({ url: 'api/common/comment', data })
export function comment() {
return getAxios({ url: 'api/common/comment' })
}

64
src/utils/h264Renderer.js Normal file
View File

@@ -0,0 +1,64 @@
// utils/h264Renderer.js
export function createH264WebCodecsRenderer(videoEl) {
// 1) 生成一个可写的视频轨,绑定到 <video>.srcObject
const trackGen = new MediaStreamTrackGenerator({ kind: 'video' });
const writer = trackGen.writable.getWriter();
const stream = new MediaStream([trackGen]);
videoEl.srcObject = stream;
// 简单的 Annex-B 关键帧判断(为了给 EncodedVideoChunk 标 type
function isKeyframeAnnexB(buf) {
const u8 = new Uint8Array(buf);
for (let i = 0; i + 4 < u8.length; i++) {
// 00 00 01
if (u8[i] === 0 && u8[i + 1] === 0 && u8[i + 2] === 1) {
const nalType = u8[i + 3] & 0x1f;
if (nalType === 5) return true; // IDR
}
// 00 00 00 01
if (u8[i] === 0 && u8[i + 1] === 0 && u8[i + 2] === 0 && u8[i + 3] === 1) {
const nalType = u8[i + 4] & 0x1f;
if (nalType === 5) return true;
}
}
return false;
}
// 2) 建立解码器:输出即写入 trackGen再立刻释放帧
const decoder = new VideoDecoder({
output: async (frame) => {
await writer.write(frame);
frame.close();
},
error: (e) => console.error('VideoDecoder error:', e)
});
// H.264Annex-BSPS/PPS 从码流里来)
decoder.configure({
codec: 'avc1.64001f', // High@3.1(占位即可)
optimizeForLatency: true
});
// 用一个单调时间戳微秒。30fps ≈ 33333us
let ts = 0;
const stepUs = 33333;
// 3) 返回一个“喂数据”的函数 & 清理函数
return {
push(buffer /* ArrayBuffer */) {
const key = isKeyframeAnnexB(buffer);
const chunk = new EncodedVideoChunk({
type: key ? 'key' : 'delta',
timestamp: ts,
data: new Uint8Array(buffer) // 视图包裹,不拷贝
});
ts += stepUs;
decoder.decode(chunk);
},
close() {
try { writer.close(); } catch { }
try { decoder.close(); } catch { }
try { videoEl.srcObject = null; } catch { }
}
};
}

View File

@@ -3,9 +3,8 @@
<el-scrollbar class="left"> <!-- 左边栏 -->
<div class="center-line"> <!-- 左边栏按钮 -->
<div v-for="(btn, index) in buttons" :key="index" style="width: 100%;">
<div v-if="btn.show?.()" class="left-button" :style="{
backgroundColor: btn.label == '关闭监测消息' ? 'red' : '',
}" @click="btn.onClick" @mouseenter="hoverIndex = index" @mouseleave="hoverIndex = null">
<div v-if="btn.show?.()" class="left-button" :style="btn.style ? btn.style() : {}" @click="btn.onClick"
@mouseenter="hoverIndex = index" @mouseleave="hoverIndex = null">
<img :src="hoverIndex === index ? btn.img.hover : btn.img.normal" alt="">
{{ btn.label }}
</div>
@@ -60,7 +59,7 @@
<ChatDialog :visible="openShowChat" :messages="chatList" />
</div>
<MultiLineInputDialog v-model:visible="showDialog" :initialText='""' :title="dialogTitle" :index="selectedDevice"
@confirm="onDialogConfirm" />
@confirm="onDialogConfirm" @cancel="stop" />
</div>
</template>
@@ -124,7 +123,7 @@ let isStop = ref(false);
//sse弹窗是否存在
let isMsgPop = ref(false);
//播放器列表
let instanceList = ref([{}, {}, {}, {}, {}, {}, {}, {}]);
let instanceList = [{}, {}, {}, {}, {}, {}, {}, {}];
//是否是在关注主播
let runType = ref(['', '', '', '', '', '', '', '']);
//屏幕尺寸系数
@@ -156,6 +155,7 @@ let istranslate = ref(false); //是否是翻译本页
let phoneXYinfo = ref(getphoneXYinfo() == null ? [{}, {}, {}, {}, {}, {}, {}, {}] : getphoneXYinfo());
// 当前悬浮的按钮索引
const hoverIndex = ref(null)
const isMonitorOn = ref(false) // false 表示关闭true 表示开启
// 你可以用这种方式声明按钮们
const buttons = [
@@ -188,25 +188,40 @@ const buttons = [
},
{
label: '打开直播',
onClick: () => brushLive(),
onClick: () => {
runType.value[0] = 'brushLive'
brushLive()
},
show: () => true,
img: {
normal: new URL('@/assets/video/leftBtn4.png', import.meta.url).href,
hover: new URL('@/assets/video/leftBtn4-4.png', import.meta.url).href
}
},
style: () => ({
backgroundColor: runType.value[0] == 'brushLive' ? 'red' : ''
})
},
{
label: '一键养号',
onClick: () => parentNum(),
onClick: () => {
if (runType.value[0] == 'like') return;
runType.value[0] = 'like'
parentNum()
},
show: () => true,
img: {
normal: new URL('@/assets/video/leftBtn5.png', import.meta.url).href,
hover: new URL('@/assets/video/leftBtn5-5.png', import.meta.url).href
}
},
style: () => ({
backgroundColor: runType.value[0] == 'like' ? 'red' : ''
})
},
{
label: '一键关注并打招呼',
onClick: () => {
if (runType.value[0] == 'follow') return;
runType.value[0] = 'follow'
showDialog.value = true
dialogTitle.value = '主播ID'
selectedDevice.value = 999
@@ -215,26 +230,29 @@ const buttons = [
img: {
normal: new URL('@/assets/video/leftBtn6.png', import.meta.url).href,
hover: new URL('@/assets/video/leftBtn6-6.png', import.meta.url).href
}
},
style: () => ({
backgroundColor: runType.value[0] == 'follow' ? 'red' : ''
})
},
{
label: '开启监测消息',
onClick: () => openMonitor(),
show: () => !isShowMes.value, // 只有在未开启时显示
img: {
normal: new URL('@/assets/video/leftBtn1.png', import.meta.url).href,
hover: new URL('@/assets/video/leftBtn1-1.png', import.meta.url).href
}
},
{
label: '关闭监测消息',
onClick: () => cloesMonitor(),
show: () => isShowMes.value, // 只有在已开启时显示
label: '监测消息',
onClick: () => {
isMonitorOn.value = !isMonitorOn.value
if (isMonitorOn.value) {
openMonitor()
} else {
cloesMonitor()
}
},
show: () => true,
img: {
normal: new URL('@/assets/video/leftBtn1.png', import.meta.url).href,
hover: new URL('@/assets/video/leftBtn1-1.png', import.meta.url).href
},
style: { backgroundColor: 'red' }
style: () => ({
backgroundColor: isMonitorOn.value ? 'red' : ''
})
},
{
label: '全部停止',
@@ -256,6 +274,31 @@ const buttons = [
}
]
const feedState = Array(8).fill(null).map(() => ({
processing: false,
pending: null, // ArrayBuffer 等最新一段
}));
function pushFrame(index, buf) {
const st = feedState[index];
if (st.processing) {
// 覆盖旧的等待帧,保留最新
st.pending = buf;
return;
}
st.processing = true;
try {
//推送帧到video
instanceList[index].converter.appendRawData(new Uint8Array(buf));
} finally {
st.processing = false;
if (st.pending) {
const next = st.pending;
st.pending = null;
// 用微任务衔接,避免递归栈增长
queueMicrotask(() => pushFrame(index, next));
}
}
}
const wsCache = new Map();
//``````````````````````````````````````````````````````````````````````````````````
@@ -264,30 +307,29 @@ const initVideoStream = (udid, index) => {
//``````````````````````````````````````````````````````````````````````````````````
// 1. 检查缓存中是否已有实例
if (wsCache.has(udid)) {
const cachedWs = wsCache.get(udid);
if (cachedWs.readyState === WebSocket.OPEN) {
return cachedWs;
const cached = wsCache.get(udid);
if (cached?.ws?.readyState === WebSocket.OPEN) {
return cached.ws;
}
// 如果连接已关闭,清除缓存并重新创建
wsCache.delete(udid);
}
// 2. 创建专用实例容器
instanceList.value[index] = {
wsVideo: null,
instanceList[index] = {
// wsVideo: null,
converter: null,
timer: null
};
//``````````````````````````````````````````````````````````````````````````````````
if (!videoElement.value) return;
// 1. 创建 h264-converter 实例
instanceList.value[index].converter = new VideoConverter(videoElement.value[udid], 60, 1);
// instanceList.value[index].converter.play();
instanceList[index].converter = new VideoConverter(videoElement.value[udid], 60, 1);
// 2. 连接 WebSocket
wslist[index] = new WebSocket(
`ws://127.0.0.1:8000/?action=proxy-adb&remote=tcp%3A8886&udid=${udid}`
);
wslist[index].binaryType = "arraybuffer";
attachTrimmerForIndex(index, 10, 2000); // 挂上修剪器
wslist[index].onopen = () => {
console.log("手机显示ws已开启");
wsActions = createWsActions(wslist);
@@ -295,12 +337,12 @@ const initVideoStream = (udid, index) => {
setTimeout(() => {
wslist[index].send(openStr);
}, 300);
wsCache.set(udid, instanceList.value[index]);
wsCache.set(udid, { ws: wslist[index], index });
};
const magicSize = stringToUtf8ByteArray('scrcpy_message');
// 3. 处理接收到的二进制数据
wslist[index].onmessage = (event) => {
const data = new Uint8Array(event.data);
//判断返回的如果是字符串为自定义返回
if (typeof event.data == 'string') {
if (isStop.value) {
@@ -359,7 +401,13 @@ const initVideoStream = (udid, index) => {
console.log('最新消息', mesBox)
console.log("翻译", istranslate.value)
if (istranslate.value == false) {
if (mesBox.position == 'right') return
if (mesBox.position == 'right') {
Back('', index)
setTimeout(() => {
Back('', index)
}, 1000)
return
}
openShowChat.value = true
console.log("执行ai")
@@ -619,21 +667,28 @@ const initVideoStream = (udid, index) => {
}
// createTaskQueue(index).next(); // 继续队列中下一个任务
}
}
//返回粘贴板内容
if (startsWithHeader(magicSize, data)) {
if (!isSend.value) {
const buffer = trimLongArray(data, magicSize);
const paste = bufferToString(buffer);
console.log('获取粘贴板内容', paste)
}
}
//视频流处理
if (instanceList.value[index].converter) {
if (isshow.value) {
instanceList.value[index].converter.appendRawData(data);
} else {
const buf = event.data; // ArrayBuffer
const view = new Uint8Array(buf); // 只创建视图,不复制
// 粘贴板消息头判断FIX: 用 view别用未定义的 data
if (startsWithHeader(magicSize, view)) {
if (!isSend.value) {
const payload = trimLongArray(view, magicSize);
const paste = bufferToString(payload);
console.log('获取粘贴板内容', paste);
}
return; // 这类消息不走视频通道
}
// 视频流
if (instanceList[index].converter && isshow.value) {
pushFrame(index, buf); // 用下方新的 pushFrame
}
}
};
// 4. 错误处理
wslist[index].onerror = (error) => {
@@ -642,7 +697,7 @@ const initVideoStream = (udid, index) => {
//``````````````````````````````````````````````````````````````````````````````````
wslist[index].onclose = (event) => {
wsCache.delete(udid)// 自动清理缓存
clearInterval(instanceList.value[index].timer); // 清理定时器// 移除缓存
clearInterval(instanceList[index].timer); // 清理定时器// 移除缓存
};
//``````````````````````````````````````````````````````````````````````````````````
};
@@ -1402,7 +1457,7 @@ function parentNum() {
function brushLive() {
isStop.value = false;
deviceInformation.value.forEach((device, index) => {
// runType.value[index] = 'brushLive'
runType.value[index] = 'brushLive'
wsActions.toLive(device.udid, index)
})
}
@@ -1497,10 +1552,91 @@ function getTransformStyle(index) {
? 'translateY(-30%)'
: 'none';
}
function attachTrimmerForIndex(index, backBufferSec = 10, intervalMs = 2000) {
const conv = instanceList[index]?.converter;
if (!conv) return;
// 如果还没创建好 MSE/SourceBuffer则等 sourceopen 再挂
const ensureAttach = () => {
const ms = conv.mediaSource;
if (!ms) return false;
if (ms.readyState !== 'open') return false;
if (!conv.sourceBuffer) return false;
return true;
};
// 先清旧的
if (conv._trimTimer) {
clearInterval(conv._trimTimer);
conv._trimTimer = null;
}
if (conv._mseListenersInstalled !== true && conv.mediaSource) {
conv._mseListenersInstalled = true;
conv.mediaSource.addEventListener('sourceopen', () => {
// MSE 重新 open 时,重新挂修剪器
attachTrimmerForIndex(index, backBufferSec, intervalMs);
});
conv.mediaSource.addEventListener('sourceclose', () => {
if (conv._trimTimer) {
clearInterval(conv._trimTimer);
conv._trimTimer = null;
}
});
conv.mediaSource.addEventListener('error', () => {
if (conv._trimTimer) {
clearInterval(conv._trimTimer);
conv._trimTimer = null;
}
});
}
// 可能还没 ready轮询等待
if (!ensureAttach()) {
const waitId = setInterval(() => {
if (ensureAttach()) {
clearInterval(waitId);
attachTrimmerForIndex(index, backBufferSec, intervalMs);
}
}, 300);
return;
}
conv._trimTimer = setInterval(() => {
// 每次都重新取,避免拿到被移除的旧 sb 引用
const currentConv = instanceList[index]?.converter;
const ms = currentConv?.mediaSource;
const sb = currentConv?.sourceBuffer;
const video = videoElement.value[deviceInformation.value[index]?.udid];
if (!currentConv || !ms || ms.readyState !== 'open' || !sb || !video) return;
if (sb.updating || video.seeking || video.readyState < 2) return;
const cur = video.currentTime || 0;
const trimTo = Math.max(0, cur - backBufferSec);
try {
// buffered 可能是多段,仅删除完全早于 trimTo 的最前一段
for (let i = 0; i < sb.buffered.length; i++) {
const start = sb.buffered.start(i);
const end = sb.buffered.end(i);
if (end < trimTo - 0.25) {
try { sb.remove(0, end); } catch { }
break;
}
}
} catch (e) {
// 包含 “SourceBuffer has been removed ...” 等异常时,停止本轮,等下次 tick 重取 sb
// console.warn('[trimmer]', e);
}
}, intervalMs);
}
function manualGc() {
window.electronAPI.manualGc()
}
</script>
<style scoped lang="less">

View File

@@ -0,0 +1,42 @@
// src/workers/decoder.worker.js
let inflight = 0;
const MAX_INFLIGHT = 4;
// 可选:识别 'scrcpy_message' 头
const MAGIC = new TextEncoder().encode('scrcpy_message');
function startsWithHeader(buffer) {
if (buffer.byteLength < MAGIC.length) return false;
const head = new Uint8Array(buffer, 0, MAGIC.length);
for (let i = 0; i < MAGIC.length; i++) if (head[i] !== MAGIC[i]) return false;
return true;
}
function bufferTailToString(buffer, offset) {
const view = new Uint8Array(buffer, offset);
return new TextDecoder('utf-8').decode(view);
}
self.onmessage = (e) => {
const { type, buffer, meta } = e.data || {};
if (type !== 'video' || !buffer) return;
// 控制消息永不丢
if (startsWithHeader(buffer)) {
const text = bufferTailToString(buffer, MAGIC.length);
self.postMessage({ type: 'clipboard', text, meta });
return;
}
// 真正的丢帧:在途已满就扔掉
if (inflight >= MAX_INFLIGHT) {
return; // 丢掉这帧
}
inflight++;
try {
const chunk = new Uint8Array(buffer);
// console.log("进入视频帧")
self.postMessage({ type: 'video', chunk, meta }, [chunk.buffer]);
} finally {
inflight--;
}
};