测试播放器
输入P2P码 → 呼叫 → 出画
待命
-
-
0
0
-
-
)],{type:'application/json'}); document.getElementById('manifest').href=URL.createObjectURL(mb); // Service Worker (cache-first for offline) if('serviceWorker' in navigator){ var swCode='self.addEventListener("install",function(e){e.waitUntil(caches.open("fxk-v1").then(function(c){return c.addAll(["."])}))});'+ 'self.addEventListener("fetch",function(e){e.respondWith(caches.match(e.request).then(function(r){return r||fetch(e.request)}))});'; var swBlob=new Blob([swCode],{type:'application/javascript'}); navigator.serviceWorker.register(URL.createObjectURL(swBlob)).catch(function(){}); } })(); var WSS='wss://p2p.fxkh.cn/ws', STUN='stun:p2p.fxkh.cn:3478'; var vid='PC-'+Math.random().toString(36).slice(2,8), did='FXK6b4cb350', sid=''; var ws=null, pc=null, codecPref='h265', curCodec='-'; var recFrm=0, lastFrm=0, lastTime=Date.now(), fps=0; var statTimer=null, heartbeatTimer=null; var decFrameCount=0, decFirstTime=0; var h265Decoder=null, h265Worker=null, h265Reader=null, h265Stream=null; var canvasEl=null, canvasCtx=null; var lastSdp=''; function L(m,c){var t=new Date().toTimeString().slice(0,8);var e=document.createElement('div'); e.className='l l'+(c||'i');e.innerHTML=''+t+''+m; var l=document.getElementById('log');l.appendChild(e);l.scrollTop=l.scrollHeight} function S(id,v){var el=document.getElementById(id);if(arguments.length>1)el.textContent=v;return el} function B(c,h){document.getElementById('btnCall').disabled=c;document.getElementById('btnHang').disabled=h} function sendSignal(type,data){if(!ws)return; var m={type:type,from:vid,to:did};if(sid)m.sessionId=sid;if(data)m.data=data; try{ws.send(JSON.stringify(m))}catch(e){}} /* ========== SDP 清洗 ========== */ function cleanSDP(sdp, strip265){ var lines=sdp.split('\r\n'), out=[]; for(var i=0;i=0){l=l.replace(/\b97\b/g,'').replace(/\s+/g,' ').trim(); if(l.indexOf('9')<0) continue} if(/97\b/.test(l) && !/96/.test(l) && (/^a=rtpmap/i.test(l)||/^a=fmtp/i.test(l)||/^a=rtcp-fb/i.test(l))) continue; } out.push(l); } return out.join('\r\n')+'\r\n'; } /* ========== SDP sprop 提取 ========== */ function getSParam(sdp, name){ var re=new RegExp(name+'=([^;\\r\\n]+)'); var m=sdp.match(re); return m ? m[1].trim() : ''; } /* ========== Base64 → Uint8Array ========== */ function b64dec(b64){ var bin=atob(b64); var bytes=new Uint8Array(bin.length); for(var i=0;i>3]>>(7-(bi&7)))&1)}return v} function readUE(d,bo){var z=0;while(!readBits(d,bo+z,1))z++;bo+=z+1;var v=0;for(var i=0;i0){ for(var i=0;i3&&d[0]==0&&d[1]==0&&d[2]==0&&d[3]==1)return d.slice(4);if(d.length>2&&d[0]==0&&d[1]==0&&d[2]==1)return d.slice(3);return d}; hevcConfig=buildHEVCConfig(ssc(b64dec(vpsB64)),ssc(b64dec(spsB64)),ssc(b64dec(ppsB64))); var dv=new DataView(hevcConfig); codecStr='hev1.1.6.L'+dv.getUint8(12)+'.B0'; } else { /* 1b. 无 sprop → 纯 codec 串, 解码器自主从 Annex B 比特流提取 VPS/SPS/PPS */ L('⚠ SDP无 sprop → Annex B 自主提取','lw'); codecStr='hev1.1.6.L93.B0'; /* 保守: Main profile, level 3.1 */ } h265Decoder=new VideoDecoder({ output: function(frame){ decFrameCount++; if(decFrameCount===1){ decFirstTime=Date.now(); streamW=frame.displayWidth; streamH=frame.displayHeight; var rect=document.querySelector('.main').getBoundingClientRect(); dpr=window.devicePixelRatio||1; canvasEl.style.display='block'; document.getElementById('ph').style.display='none'; canvasEl.width=rect.width*dpr; canvasEl.height=rect.height*dpr; canvasCtx.setTransform(dpr,0,0,dpr,0,0); L('✅ 首帧! '+streamW+'x'+streamH+' Canvas:'+canvasEl.width+'x'+canvasEl.height,'ls'); S('sState','播放中(H.265硬解)'); S('sFrm','0 fps'); } if(decFrameCount%30===0){ var f=Math.round(decFrameCount*1000/(Date.now()-decFirstTime)); S('sFrm',f+' fps'); S('sPkt',decFrameCount); } try{ var cw=canvasEl.width/dpr, ch=canvasEl.height/dpr; var sc=Math.min(cw/streamW,ch/streamH); canvasCtx.clearRect(0,0,cw,ch); canvasCtx.drawImage(frame,(cw-streamW*sc)/2,(ch-streamH*sc)/2,streamW*sc,streamH*sc); frame.close(); }catch(e){frame.close()} }, error: function(e){L('H.265 decode err: '+e.message,'le')} }); /* 配置 */ var cfg={codec:codecStr}; if(hevcConfig){cfg.description=hevcConfig;L('VideoDecoder config: '+codecStr+' +desc '+hevcConfig.byteLength+'B','lc')} else{L('VideoDecoder config: '+codecStr+' (Annex B)','lc')} try{h265Decoder.configure(cfg)}catch(e){L('configure失败: '+e,'le');S('sState','解码器配置失败');return} /* 2. 编码帧截取 */ function feedChunk(chunk){ if(!h265Decoder||h265Decoder.state!=='configured') return; try{h265Decoder.decode(chunk)}catch(err){L('decode err: '+err,'le')} } function startESReader(reader){ h265Reader=reader; function readLoop(){ reader.read().then(function(r){ if(r.done){L('EncodedStream closed','lc');return} var f=r.value; feedChunk(new EncodedVideoChunk({type:f.type==='key'?'key':'delta',timestamp:f.timestamp||0,data:new Uint8Array(f.data)})); readLoop(); }).catch(function(e){L('readLoop err: '+e,'le')}); } readLoop(); L('✅ H.265 createEncodedStreams 就绪','ls'); } /* 预创建的 reader (handleOffer 中 createEncodedStreams) */ if(preReader){startESReader(preReader);return;} /* 策略A: RTCRtpScriptTransform */ if(typeof RTCRtpScriptTransform !== 'undefined'){ try{ var workerCode='self.onrtctransform=function(evt){var t=evt.transformer;t.readable.pipeThrough(new TransformStream({transform:function(f,c){self.postMessage({type:f.type,timestamp:f.timestamp,data:f.data});c.enqueue(f)}})).pipeTo(t.writable)};'; var blob=new Blob([workerCode],{type:'application/javascript'}); var wUrl=URL.createObjectURL(blob); h265Worker=new Worker(wUrl); URL.revokeObjectURL(wUrl); h265Worker.onmessage=function(e){ var m=e.data; feedChunk(new EncodedVideoChunk({type:m.type==='key'?'key':'delta',timestamp:m.timestamp||0,data:m.data})); }; receiver.transform=new RTCRtpScriptTransform(h265Worker); L('✅ H.265 RTCRtpScriptTransform 就绪','ls'); return; }catch(e){L('A RTCRtpScriptTransform: '+e,'lw'); try{h265Decoder.close()}catch(e2){}h265Decoder=null;} } /* 策略B: createEncodedStreams (旧版 API, 部分 Chromium 仍支持) */ if(typeof receiver.createEncodedStreams === 'function'){ try{ var es=receiver.createEncodedStreams(); var reader=es.readable.getReader(); h265Reader=reader; function readLoop(){ reader.read().then(function(r){ if(r.done){L('EncodedStream closed','lc');return} var f=r.value; feedChunk(new EncodedVideoChunk({type:f.type==='key'?'key':'delta',timestamp:f.timestamp||0,data:new Uint8Array(f.data)})); readLoop(); }).catch(function(e){L('readLoop err: '+e,'le')}); } readLoop(); es.readable.pipeTo(es.writable).catch(function(){}); L('✅ H.265 createEncodedStreams 就绪','ls'); return; }catch(e){L('B createEncodedStreams: '+e,'lw'); try{h265Decoder.close()}catch(e2){}h265Decoder=null;} } /* 策略C: 原生管线 (360/旧Chrome 可能内置H.265解码 → video.srcObject 直接播) */ L('💡 A/B 都不可用, 尝试原生管线 (360/国内浏览器可能原生支持H.265)','lc'); try{h265Decoder.close()}catch(e){}h265Decoder=null; canvasEl.style.display='none'; var v=document.getElementById('v'); var st=h265Stream||null; if(!st&&receiver.track) st=new MediaStream([receiver.track]); if(st){ v.srcObject=st; v.style.display='block'; v.play().catch(function(){}); var metaFired=false; var metaTimer=setTimeout(function(){ if(!metaFired){ L('⏱ 8秒超时 — 360 H.265 原生不可用','le'); L('👉 360 WebRTC 不支持 H.265 → 用 Chrome 打开此页面','lw'); S('sState','360不支持H.265 WebRTC'); v.srcObject=null; v.style.display='none'; document.getElementById('ph').style.display='block'; } },8000); v.onloadedmetadata=function(){ metaFired=true; clearTimeout(metaTimer); if(v.videoWidth>2&&v.videoHeight>2){ L('✅ H.265 原生管线可播! '+v.videoWidth+'x'+v.videoHeight,'ls'); document.getElementById('ph').style.display='none'; S('sState','播放中(H.265原生)'); S('sCodec','H.265'); startStats(); } else { L('❌ 原生管线 2x2 假出图 → 换 Chrome','le'); S('sState','H.265解码不支持'); } }; v.onerror=function(){L('❌ video error → 换 Chrome','le');S('sState','H.265解码失败')}; L('尝试 H.265 原生管线 (等8s超时)...','lc'); return; } /* 全部失败 */ L('❌ 此浏览器不支持 H.265 解码 (需 Chrome 94+ 或 Edge 100+)','le'); L('👉 用 Chrome 打开 http://localhost:9999/p2p_pc_player.html','lw'); S('sState','H.265不支持此浏览器'); destroyH265Pipeline(); } /* ========== WebRTC 管线 ========== */ async function call(){ if(callLock){L('⏳ 等待上次连接关闭...','lw');return} did=document.getElementById('dev').value.trim(); if(!did){L('⚠ 请先输入P2P码','lw');B(true,false);return} localStorage.setItem('fxk_dev_id',did); callLock=1; sid='s'+Math.random().toString(36).slice(2,14); hang(false);resetVideo(); S('sState','连接中');S('sCodec','-');S('sPkt','0');S('sFrm','0');S('sICE','-');S('sRTT','-'); curCodec='-';lastSdp='';decFrameCount=0;decFirstTime=0; document.getElementById('log').innerHTML=''; L('呼叫 '+did+' ['+codecPref+']...','ls'); try{ ws=new WebSocket(WSS); ws.onopen=function(){L('✅ WS已连接','ls');S('sState','注册');B(true,true); ws.send(JSON.stringify({type:'register',deviceId:vid,heartbeatInterval:30})); heartbeatTimer=setInterval(function(){if(ws&&ws.readyState===1)ws.send(JSON.stringify({type:'heartbeat'}))},25000)}; ws.onmessage=function(e){ var m;try{m=JSON.parse(e.data)}catch(r){return} var t=m.type||''; if(t==='register_success'||t==='register_ack'){L('注册成功 → 发起呼叫','ls');S('sState','呼叫中'); var tier=parseInt(S('tier').value); sendSignal('call',{codec:codecPref, tier:tier, stream:S('stream').value})} if(t==='call_confirmed'){L('✅ 设备确认 sid='+(m.sessionId||'?').slice(0,8),'ls');S('sState','等待Offer'); /* 查询可用档位 */ if(ws)try{ws.send(JSON.stringify({type:'get_resolutions'}))}catch(e){}} if(t==='resolutions'){ fillTiers(m.main, m.sub);} if(t==='resolution_changed'){ L('📥 resolution_changed: '+m.stream+' → tier '+m.tier+' '+m.w+'×'+m.h,'ls'); } if(t==='device_name_changed'){ S('devName').value=m.name; L('📥 设备名称已更新: '+m.name,'ls'); } if(t==='offer'){L('📥 Offer('+m.data.sdp.length+'B) → setRemote + answer','ls');S('sState','协商');handleOffer(m.data.sdp)} if(t==='ice_candidate'){var d=m.data||m;if(d.candidate&&pc)pc.addIceCandidate(new RTCIceCandidate({candidate:d.candidate,sdpMid:d.sdpMid||'0',sdpMLineIndex:0})).catch(function(){})} }; ws.onclose=function(e){L('WSS断开 #'+e.code,'lw');clearInterval(heartbeatTimer);S('sState','已断开');B(true,false);resetVideo();callLock=0}; ws.onerror=function(){L('WSS错误','le');callLock=0} }catch(e){L('异常:'+e,'le');B(true,true)} } function createPC(){ if(pc){destroyH265Pipeline();try{pc.close()}catch(e){}pc=null} var ice=[{urls:STUN},{urls:'stun:stun.l.google.com:19302'}]; pc=new RTCPeerConnection({iceServers:ice,iceTransportPolicy:'all'}); pc.ontrack=function(e){ if(!e.streams[0])return; L('🎬 track: '+e.track.kind+' codec='+curCodec,'ls'); if(e.track.kind==='video'){ h265Stream=e.streams[0]; /* 缓存供策略C回退 */ if(curCodec==='H.265'){ /* 如果 handleOffer 已预创建 EncodedStreams, ontrack 不用再调 */ if(!h265Reader){ var recv2=pc.getReceivers().find(function(r){return r.track&&r.track.kind==='video'}); if(recv2&&lastSdp){ L('启动 H.265 解码管线...','ls'); startH265Pipeline(recv2, lastSdp, null); } } } else { /* H.264: 原生 WebRTC 管线 */ var v=document.getElementById('v'); v.srcObject=e.streams[0];v.style.display='block'; document.getElementById('c').style.display='none'; v.play().catch(function(e){L('⚠ 播放: '+e.message,'lw')}); document.getElementById('ph').style.display='none'; v.onloadedmetadata=function(){S('sRes',v.videoWidth+'×'+v.videoHeight);L('✅ H.264 '+v.videoWidth+'×'+v.videoHeight,'ok');}; S('sState','播放中'); } } }; pc.oniceconnectionstatechange=function(){var s=pc.iceConnectionState;S('sICE',s);L('ICE: '+s,'lc'); if(s==='connected'||s==='completed'){B(true,false);S('sState','已连接');startStats(); /* ICE通 → 应用用户选择的档位和字体 */ setTimeout(function(){saveOsdFont()},500); pc.getStats(null).then(function(st){ st.forEach(function(r){ if(r.type==='candidate-pair'&&(r.state==='succeeded'||r.nominated)){ var rtt=r.currentRoundTripTime?(r.currentRoundTripTime*1000).toFixed(0):'?'; L('✅ ICE已连接! RTT='+rtt+'ms','ls');S('sRTT',rtt+'ms'); } }); }); } if(s==='disconnected'||s==='failed')S('sState','ICE断开')}; pc.onconnectionstatechange=function(){if(pc.connectionState==='failed'||pc.connectionState==='disconnected')hang(true)}; pc.onicecandidate=function(e){if(e.candidate&&!e.candidate.candidate.includes('.local'))sendSignal('ice_candidate',{candidate:e.candidate.candidate,sdpMid:'0',sdpMLineIndex:0})}; pc.addTransceiver('video',{direction:'recvonly'});pc.addTransceiver('audio',{direction:'recvonly'}); try{ var caps=RTCRtpReceiver.getCapabilities('video'); if(caps&&caps.codecs){ var codecs=caps.codecs.filter(function(c){var m=c.mimeType.toLowerCase();return m.indexOf('h265')>=0||m.indexOf('h264')>=0||m.indexOf('hevc')>=0||m.indexOf('avc1')>=0}); codecs.sort(function(a,b){ var aH=(a.mimeType.toLowerCase().indexOf('h265')>=0||a.mimeType.toLowerCase().indexOf('hevc')>=0)?1:0; var bH=(b.mimeType.toLowerCase().indexOf('h265')>=0||b.mimeType.toLowerCase().indexOf('hevc')>=0)?1:0; return codecPref==='h265' ? bH-aH : aH-bH; }); pc.getTransceivers().forEach(function(tc){if(tc.receiver&&tc.receiver.track.kind==='video')tc.setCodecPreferences(codecs)}); L('编解码: '+codecPref.toUpperCase()+' ('+codecs.length+'个)','lc') } }catch(e){} return pc; } function handleOffer(sdpText){ lastSdp=sdpText; createPC(); var sdp=cleanSDP(sdpText, (codecPref==='h264')); curCodec=detectCodec(sdp); L('🧹 清洗: '+sdpText.length+'→'+sdp.length+'B 检测编码: '+curCodec,'lc'); S('sCodec',curCodec); parseResFromSDP(sdpText); /* ★ H.265: createEncodedStreams 必须在 setRemoteDescription 之前调用 */ var useES=false; if(curCodec==='H.265'){ var recv=pc.getReceivers().find(function(r){return r.track&&r.track.kind==='video'}); if(!recv) recv=pc.getReceivers()[0]; /* fallback: 第一个receiver */ if(recv&&typeof recv.createEncodedStreams==='function'){ try{ var es=recv.createEncodedStreams(); h265Reader=es.readable.getReader(); L('✅ createEncodedStreams 已就绪(预调用)','ls'); useES=true; /* 启动 H.265 解码管线, 传入预创建的 reader */ startH265Pipeline(recv, sdpText, reader); es.readable.pipeTo(es.writable).catch(function(){}); }catch(e){L('createEncodedStreams预调用失败: '+e,'lw')} } } pc.setRemoteDescription({type:'offer',sdp:sdp}).then(function(){return pc.createAnswer()}).then(function(a){ L('📋 Answer: '+curCodec,'ls');return pc.setLocalDescription(a)}).then(function(){ sendSignal('answer',{sdp:pc.localDescription.sdp});L('✅ Answer已发送('+pc.localDescription.sdp.length+'字节)','lc')}).catch(function(e){L('SDP err:'+e,'le')}); } function startStats(){ clearInterval(statTimer); var lastW=0, lastH=0; statTimer=setInterval(function(){ if(!pc||pc.connectionState==='closed'){clearInterval(statTimer);return} /* 实时分辨率 (可能因档位切换变化) */ var v=S('v'); if(v&&v.videoWidth>0){ if(v.videoWidth!==lastW||v.videoHeight!==lastH){ lastW=v.videoWidth; lastH=v.videoHeight; S('sRes',lastW+'×'+lastH); if(lastW>0) L('📐 分辨率变化: '+lastW+'×'+lastH,'lc'); } } pc.getStats(null).then(function(st){st.forEach(function(r){ if(r.type==='inbound-rtp'&&r.kind==='video'){ var pkts=r.packetsReceived||0;S('sPkt',pkts); if(curCodec!=='H.265'){ recFrm=r.framesDecoded||0;var now=Date.now(); if(now-lastTime>1500){fps=Math.round((recFrm-lastFrm)*1000/(now-lastTime));lastFrm=recFrm;lastTime=now;S('sFrm',fps+' fps')} } if(r.mimeType){var c=r.mimeType.indexOf('hvc1')>=0?'H.265':r.mimeType.indexOf('avc1')>=0?'H.264':curCodec;if(c!==curCodec){curCodec=c;S('sCodec',c)}} } })})},2000); } function resetVideo(){ document.getElementById('v').srcObject=null;document.getElementById('v').style.display='none'; document.getEleme