修复若干bug和ui
This commit is contained in:
@@ -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
64
src/utils/h264Renderer.js
Normal 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.264,Annex-B(SPS/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 { }
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
42
src/workers/decoder.worker.js
Normal file
42
src/workers/decoder.worker.js
Normal 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--;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user