Compare commits
6 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
5ea740ea24 | ||
![]() |
f491b21636 | ||
![]() |
695d719b98 | ||
![]() |
d1f26ede2f | ||
![]() |
bf57130f98 | ||
e871d5c268 |
@ -1,7 +1,8 @@
|
|||||||
FOX 12:207028615:8VSB:49:52:3
|
|
||||||
ION:521028615:8VSB:49:52:3
|
ION:521028615:8VSB:49:52:3
|
||||||
KATU:533028615:8VSB:49:52:3
|
KATU:533028615:8VSB:49:52:3
|
||||||
KOIN-HD:539028615:8VSB:49:52:3
|
KOIN-HD:539028615:8VSB:49:52:3
|
||||||
KGW:545028615:8VSB:49:52:3
|
KGW:545028615:8VSB:49:52:3
|
||||||
|
KJYY-LD:563028615:8VSB:49:52:1
|
||||||
KBLN-DT:575028615:8VSB:49:52:1
|
KBLN-DT:575028615:8VSB:49:52:1
|
||||||
TBN HD:581028615:8VSB:49:52:3
|
TBN HD:581028615:8VSB:49:52:3
|
||||||
|
KUNP-LD:593028615:8VSB:49:52:3
|
@ -6,7 +6,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:scss": "npx sass src/static/css:dist/static/css",
|
"build:scss": "npx sass src/static/css:dist/static/css",
|
||||||
"copy:html": "cp src/static/index.html dist/static",
|
"copy:html": "cp src/static/*.* dist/static",
|
||||||
"build:js:fe": "npx tsc src/static/js/*.ts --outDir dist/static/js",
|
"build:js:fe": "npx tsc src/static/js/*.ts --outDir dist/static/js",
|
||||||
"build:js:be": "npx tsc --skipLibCheck src/*.ts --outDir dist",
|
"build:js:be": "npx tsc --skipLibCheck src/*.ts --outDir dist",
|
||||||
"build": "npm run build:js:fe && npm run build:js:be && npm run build:scss && npm run copy:html",
|
"build": "npm run build:js:fe && npm run build:js:be && npm run build:scss && npm run copy:html",
|
||||||
@ -17,6 +17,6 @@
|
|||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.2",
|
||||||
"@types/ws": "^8.5.13",
|
"@types/ws": "^8.5.13",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.8.2"
|
"typescript": "^5.8.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -8,7 +8,7 @@ export default class HttpServer {
|
|||||||
private httpServer: http.Server;
|
private httpServer: http.Server;
|
||||||
private port: number;
|
private port: number;
|
||||||
private root: string;
|
private root: string;
|
||||||
public constructor(port: number, root: string, tune: (ch: string, adp?: number) => void, getChannels: ()=>string[], getSignal: (adapter:number)=>object) {
|
public constructor(port: number, root: string, tune: (ch: string, adp?: number) => void, getChannels: ()=>string[]) {
|
||||||
this.port = port;
|
this.port = port;
|
||||||
this.root = root;
|
this.root = root;
|
||||||
this.httpServer = http.createServer((req, res) => {
|
this.httpServer = http.createServer((req, res) => {
|
||||||
@ -27,11 +27,6 @@ export default class HttpServer {
|
|||||||
body = JSON.stringify(getChannels());
|
body = JSON.stringify(getChannels());
|
||||||
status = 200;
|
status = 200;
|
||||||
break;
|
break;
|
||||||
case "signal":
|
|
||||||
const adapter = parseInt(url.searchParams.get('adapter'));
|
|
||||||
body = JSON.stringify(getSignal(adapter));
|
|
||||||
status = 200;
|
|
||||||
break;
|
|
||||||
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
@ -20,16 +20,17 @@ const tune = (reqChannel: string, reqAdapter?: number) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const getChannels = () =>
|
const getChannels = zap.getChannels.bind(zap)
|
||||||
zap.getChannels();
|
const getMetaData0 = zap.getMetaData.bind(zap,0)
|
||||||
|
const getMetaData1 = zap.getMetaData.bind(zap,1)
|
||||||
|
|
||||||
const getSignal = (adapter: number) =>
|
|
||||||
zap.getSignal(adapter)
|
|
||||||
|
|
||||||
const httpServer = new HttpServer(HTTP_PORT, STATIC_ROOT, tune, getChannels, getSignal);
|
const httpServer = new HttpServer(HTTP_PORT, STATIC_ROOT, tune, getChannels);
|
||||||
const tvWebSocket0 = new TVWebSocket(WS_PORT, TV_DEV_0);
|
const tvWebSocket0 = new TVWebSocket(WS_PORT, TV_DEV_0, getMetaData0);
|
||||||
const tvWebSocket1 = new TVWebSocket(WS_PORT + 1, TV_DEV_1);
|
const tvWebSocket1 = new TVWebSocket(WS_PORT + 1, TV_DEV_1, getMetaData1);
|
||||||
httpServer.start();
|
httpServer.start();
|
||||||
|
tune('ION',0);
|
||||||
|
tune("KGW",1);
|
||||||
|
|
||||||
|
|
||||||
process.stdin.setEncoding("utf8");
|
process.stdin.setEncoding("utf8");
|
||||||
|
BIN
src/static/favicon.ico
Normal file
BIN
src/static/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.4 KiB |
@ -2,7 +2,7 @@
|
|||||||
<html>
|
<html>
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<title>WebRTC Stream</title>
|
<title>PDX Airwave TV</title>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link rel="stylesheet" href="css/index.css" />
|
<link rel="stylesheet" href="css/index.css" />
|
||||||
@ -11,24 +11,23 @@
|
|||||||
<body>
|
<body>
|
||||||
<header></header>
|
<header></header>
|
||||||
<main>
|
<main>
|
||||||
<h1>Video streams</h1>
|
<h1>Portland Airwave Television</h1>
|
||||||
<h2>WebRTC</h2>
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="player">
|
<div class="player">
|
||||||
<video id="video0" autoplay playsinline controls></video>
|
<h2 id="station-0">N/A</h2>
|
||||||
|
<video id="video-0" autoplay playsinline controls></video>
|
||||||
<div id="channel-container-0" class="channel-group"></div>
|
<div id="channel-container-0" class="channel-group"></div>
|
||||||
<button onClick="tune(0)">Tune</button>
|
<button onClick="tune(0)">Tune</button>
|
||||||
<p id="signal-0">N/A</p>
|
<h3 id="signal-0"></h3>
|
||||||
<button onClick="getSignal(0)">Get Signal</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="player">
|
<div class="player">
|
||||||
<video id="video1" autoplay playsinline controls></video>
|
<h2 id="station-1">N/A</h2>
|
||||||
|
<video id="video-1" autoplay playsinline controls></video>
|
||||||
<div id="channel-container-1" class="channel-group"></div>
|
<div id="channel-container-1" class="channel-group"></div>
|
||||||
<button onClick="tune(1)">Tune</button>
|
<button onClick="tune(1)">Tune</button>
|
||||||
<p id="signal-1">N/A</p>
|
<h3 id="signal-1"></h3>
|
||||||
<button onClick="getSignal(1)">Get Signal</button>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
@ -46,16 +46,4 @@ const tune = (adapter = 0) => {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSignal = (adapter = 0) => {
|
|
||||||
const signalElement = document.getElementById(`signal-${adapter}`);
|
|
||||||
if (!signalElement) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fetch(`/api/signal?adapter=${adapter}`).then(res =>
|
|
||||||
res.json()
|
|
||||||
).then((strength: any) => {
|
|
||||||
signalElement.innerHTML = `Signal: ${strength.signal} C/N: ${strength.cn}`
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
populateChannels(PLAYERS);
|
populateChannels(PLAYERS);
|
@ -13,10 +13,40 @@ const config = {
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
const pc0 = new RTCPeerConnection(config);
|
|
||||||
const pc1 = new RTCPeerConnection(config);
|
|
||||||
const video0 = document.getElementById('video0') as HTMLVideoElement;
|
interface IData {
|
||||||
const video1 = document.getElementById('video1') as HTMLVideoElement;
|
channel: string,
|
||||||
|
signal: number,
|
||||||
|
cn: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const pc0 = new RTCPeerConnection(isSecure ? config : {});
|
||||||
|
const pc1 = new RTCPeerConnection(isSecure ? config : {});
|
||||||
|
const video0 = document.getElementById('video-0') as HTMLVideoElement;
|
||||||
|
const video1 = document.getElementById('video-1') as HTMLVideoElement;
|
||||||
|
const station0 = document.getElementById('station-0') as HTMLHeadElement;
|
||||||
|
const station1 = document.getElementById('station-1') as HTMLHeadElement;
|
||||||
|
const signal0 = document.getElementById('signal-0') as HTMLHeadElement;
|
||||||
|
const signal1 = document.getElementById('signal-1') as HTMLHeadElement;
|
||||||
|
|
||||||
|
// 0
|
||||||
|
const dataChannel0 = pc0.createDataChannel('meta');
|
||||||
|
console.log("📡 Data channel created by client");
|
||||||
|
|
||||||
|
dataChannel0.onopen = () => {
|
||||||
|
console.log('📬 Client: Data channel opened');
|
||||||
|
};
|
||||||
|
|
||||||
|
dataChannel0.onmessage = (event) => {
|
||||||
|
const json = JSON.parse(event.data) as IData;
|
||||||
|
station0.textContent = json.channel;
|
||||||
|
signal0.textContent = `Signal: ${json.signal}\tC/N: ${json.cn}`
|
||||||
|
};
|
||||||
|
|
||||||
|
dataChannel0.onclose = () => {
|
||||||
|
console.log("📴 Client: Data channel closed");
|
||||||
|
};
|
||||||
|
|
||||||
// 0
|
// 0
|
||||||
pc0.ontrack = (event) => {
|
pc0.ontrack = (event) => {
|
||||||
@ -30,12 +60,23 @@ pc0.onicecandidate = ({ candidate }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const restartIce = (pc, ws) => {
|
||||||
|
setInterval(async () => {
|
||||||
|
const offer = await pc.createOffer({ iceRestart: true });
|
||||||
|
await pc.setLocalDescription(offer);
|
||||||
|
ws.send(JSON.stringify({ type: 'offer', data: offer }));
|
||||||
|
}, 50000)
|
||||||
|
}
|
||||||
|
|
||||||
ws0.onopen = async () => {
|
ws0.onopen = async () => {
|
||||||
pc0.addTransceiver('video', { direction: 'recvonly' });
|
pc0.addTransceiver('video', { direction: 'recvonly' });
|
||||||
pc0.addTransceiver('audio', { direction: 'recvonly' })
|
pc0.addTransceiver('audio', { direction: 'recvonly' })
|
||||||
const offer = await pc0.createOffer();
|
const offer = await pc0.createOffer();
|
||||||
await pc0.setLocalDescription(offer);
|
await pc0.setLocalDescription(offer);
|
||||||
ws0.send(JSON.stringify({ type: 'offer', data: offer }));
|
ws0.send(JSON.stringify({ type: 'offer', data: offer }));
|
||||||
|
if (isSecure) {
|
||||||
|
restartIce(pc0,ws0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ws0.onmessage = async (message) => {
|
ws0.onmessage = async (message) => {
|
||||||
@ -49,6 +90,24 @@ ws0.onmessage = async (message) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 1
|
// 1
|
||||||
|
const dataChannel1 = pc1.createDataChannel('meta');
|
||||||
|
console.log("📡 Data channel created by client");
|
||||||
|
|
||||||
|
dataChannel1.onopen = () => {
|
||||||
|
console.log('📬 Client: Data channel opened');
|
||||||
|
};
|
||||||
|
|
||||||
|
dataChannel1.onmessage = (event) => {
|
||||||
|
const json = JSON.parse(event.data) as IData;
|
||||||
|
station1.textContent = json.channel;
|
||||||
|
signal1.textContent = `Signal: ${json.signal}\tC/N: ${json.cn}`
|
||||||
|
};
|
||||||
|
|
||||||
|
dataChannel1.onclose = () => {
|
||||||
|
console.log("📴 Client: Data channel closed");
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
pc1.ontrack = (event) => {
|
pc1.ontrack = (event) => {
|
||||||
console.log("Received track event", event.streams);
|
console.log("Received track event", event.streams);
|
||||||
video1.srcObject = event.streams[0];
|
video1.srcObject = event.streams[0];
|
||||||
@ -66,6 +125,9 @@ ws1.onopen = async () => {
|
|||||||
const offer = await pc1.createOffer();
|
const offer = await pc1.createOffer();
|
||||||
await pc1.setLocalDescription(offer);
|
await pc1.setLocalDescription(offer);
|
||||||
ws1.send(JSON.stringify({ type: 'offer', data: offer }));
|
ws1.send(JSON.stringify({ type: 'offer', data: offer }));
|
||||||
|
if (isSecure) {
|
||||||
|
restartIce(pc1,ws1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ws1.onmessage = async (message) => {
|
ws1.onmessage = async (message) => {
|
||||||
|
41
src/ws.ts
41
src/ws.ts
@ -4,12 +4,12 @@ import * as ws from 'ws';
|
|||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
const WIDTH = 640; // Video width
|
const WIDTH = 640; // Video width
|
||||||
const HEIGHT = 480; // Video height
|
const HEIGHT = 360; // Video height
|
||||||
const FRAME_SIZE = WIDTH * HEIGHT * 1.5; // YUV420p frame size (460800 bytes)
|
const FRAME_SIZE = WIDTH * HEIGHT * 1.5; // YUV420p frame size (460800 bytes)
|
||||||
|
|
||||||
export default class TVWebSocket {
|
export default class TVWebSocket {
|
||||||
videoDevice: string;
|
videoDevice: string;
|
||||||
public constructor(port: number, videoDevice) {
|
public constructor(port: number, videoDevice, getMetaData: ()=>any) {
|
||||||
this.videoDevice = videoDevice
|
this.videoDevice = videoDevice
|
||||||
const ffmpegProcess = this.startFFmpeg();
|
const ffmpegProcess = this.startFFmpeg();
|
||||||
const videoTrack = this.createVideoTrack(ffmpegProcess);
|
const videoTrack = this.createVideoTrack(ffmpegProcess);
|
||||||
@ -25,7 +25,29 @@ export default class TVWebSocket {
|
|||||||
|
|
||||||
wss.on('connection', async (ws: ws.WebSocket) => {
|
wss.on('connection', async (ws: ws.WebSocket) => {
|
||||||
const peerConnection: RTCPeerConnection = this.createPeerConnection(videoTrack, audioTrack);
|
const peerConnection: RTCPeerConnection = this.createPeerConnection(videoTrack, audioTrack);
|
||||||
|
peerConnection.ondatachannel = (event) => {
|
||||||
|
const dataChannel = event.channel; // This is the data channel created by the client
|
||||||
|
|
||||||
|
dataChannel.onopen = () => {
|
||||||
|
console.log('📬 Server: Data channel opened');
|
||||||
|
// Now you can send data through the channel
|
||||||
|
setInterval(() => {
|
||||||
|
if (dataChannel.readyState === 'open') {
|
||||||
|
const metaData = getMetaData(); // Example function to fetch data
|
||||||
|
dataChannel.send(JSON.stringify(metaData)); // Send data to the client
|
||||||
|
}
|
||||||
|
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
dataChannel.onmessage = (event) => {
|
||||||
|
console.log("📦 Server received message:", event.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
dataChannel.onclose = () => {
|
||||||
|
console.log("📴 Server: Data channel closed");
|
||||||
|
};
|
||||||
|
}
|
||||||
ws.on('message', async (message: Buffer) => {
|
ws.on('message', async (message: Buffer) => {
|
||||||
const { type, data } = JSON.parse(message.toString());
|
const { type, data } = JSON.parse(message.toString());
|
||||||
|
|
||||||
@ -66,21 +88,26 @@ export default class TVWebSocket {
|
|||||||
// Function to start FFmpeg and capture raw video
|
// Function to start FFmpeg and capture raw video
|
||||||
startFFmpeg = (): ChildProcessWithoutNullStreams => {
|
startFFmpeg = (): ChildProcessWithoutNullStreams => {
|
||||||
const p = spawn('ffmpeg', [
|
const p = spawn('ffmpeg', [
|
||||||
'-loglevel', 'debug',
|
// '-loglevel', 'debug',
|
||||||
'-i', this.videoDevice,
|
'-i', this.videoDevice,
|
||||||
|
'-framerate' ,'59.94',
|
||||||
// Video
|
// Video
|
||||||
'-map', '0:v:0',
|
'-map', '0:v:0',
|
||||||
'-vf', `scale=${WIDTH}:${HEIGHT}`,
|
'-r', '30',
|
||||||
|
'-vf', `scale=${WIDTH}:${HEIGHT}:flags=fast_bilinear`,
|
||||||
'-vcodec', 'rawvideo',
|
'-vcodec', 'rawvideo',
|
||||||
|
'-preset', 'ultrafast',
|
||||||
|
'-b:v' ,'1M',
|
||||||
'-pix_fmt', 'yuv420p',
|
'-pix_fmt', 'yuv420p',
|
||||||
'-f', 'rawvideo',
|
'-f', 'rawvideo',
|
||||||
|
// '-threads', '1',
|
||||||
|
|
||||||
//quality
|
//quality
|
||||||
|
|
||||||
'-fflags', '+discardcorrupt',
|
'-fflags', '+discardcorrupt',
|
||||||
'-err_detect', 'ignore_err',
|
'-err_detect', 'ignore_err',
|
||||||
'-analyzeduration', '100M',
|
'-analyzeduration', '500k',
|
||||||
'-probesize', '100M',
|
'-probesize', '500k',
|
||||||
|
|
||||||
'pipe:3',
|
'pipe:3',
|
||||||
|
|
||||||
|
37
src/zap.ts
37
src/zap.ts
@ -5,10 +5,10 @@ export interface IZap {
|
|||||||
process: ChildProcessWithoutNullStreams | null,
|
process: ChildProcessWithoutNullStreams | null,
|
||||||
channel: string,
|
channel: string,
|
||||||
adapter: 0 | 1
|
adapter: 0 | 1
|
||||||
strength: {
|
|
||||||
signal: string;
|
signal: string;
|
||||||
cn: string;
|
cn: string;
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class Zap {
|
export default class Zap {
|
||||||
@ -23,21 +23,21 @@ export default class Zap {
|
|||||||
public constructor(fileName = "dvb_channel.conf", channel = "ION") {
|
public constructor(fileName = "dvb_channel.conf", channel = "ION") {
|
||||||
const zap0: IZap = {
|
const zap0: IZap = {
|
||||||
process: null,
|
process: null,
|
||||||
channel,
|
channel: "None",
|
||||||
adapter: 0,
|
adapter: 0,
|
||||||
strength: {
|
|
||||||
signal: "None",
|
signal: "None",
|
||||||
cn: "None"
|
cn: "None"
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const zap1: IZap = {
|
const zap1: IZap = {
|
||||||
process: null,
|
process: null,
|
||||||
channel,
|
channel: "None",
|
||||||
adapter: 1,
|
adapter: 1,
|
||||||
strength: {
|
|
||||||
signal: "None",
|
signal: "None",
|
||||||
cn: "None"
|
cn: "None"
|
||||||
}
|
|
||||||
}
|
}
|
||||||
this.zap0 = zap0;
|
this.zap0 = zap0;
|
||||||
this.zap1 = zap1;
|
this.zap1 = zap1;
|
||||||
@ -53,16 +53,9 @@ export default class Zap {
|
|||||||
return this.channelNameList;
|
return this.channelNameList;
|
||||||
}
|
}
|
||||||
|
|
||||||
public getSignal(adapter: number) {
|
public getMetaData(adapter: 0 | 1) {
|
||||||
if(adapter == 0){
|
const { channel, signal, cn } = adapter === 0 ? this.zap0 : this.zap1
|
||||||
return this.zap0.strength;
|
return { channel, signal, cn }
|
||||||
}
|
|
||||||
else if (adapter == 1){
|
|
||||||
return this.zap1.strength;
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
return {signal: 'N/A', cn: 'N/A'}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private nextChannel(channel: string): string {
|
private nextChannel(channel: string): string {
|
||||||
@ -132,16 +125,14 @@ export default class Zap {
|
|||||||
|
|
||||||
|
|
||||||
zap.process.stderr.on("data", (data) => {
|
zap.process.stderr.on("data", (data) => {
|
||||||
const output = data.toString();
|
const output: string = data.toString();
|
||||||
|
|
||||||
if (/Lock/.test(output)) {
|
if (/Lock/.test(output)) {
|
||||||
clearTimeout(lockTimer);
|
clearTimeout(lockTimer);
|
||||||
const match = output.match(this.regex);
|
const match = output.match(this.regex);
|
||||||
zap.channel = verifiedChannel;
|
zap.channel = verifiedChannel;
|
||||||
zap.strength = {
|
zap.signal = match?.[1],
|
||||||
signal: match[1],
|
zap.cn = match?.[3]
|
||||||
cn: match[3]
|
|
||||||
}
|
|
||||||
resolve(zap);
|
resolve(zap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user