diff --git a/src/broadcast/rtc_streamer.ts b/src/broadcast/rtc_streamer.ts new file mode 100644 index 0000000..e8358c8 --- /dev/null +++ b/src/broadcast/rtc_streamer.ts @@ -0,0 +1,113 @@ +type StreamerState = "HELLO" | "OFFER" | "ANSWER" | "CONNECTED"; + +export type ConnectionStateEnum = "NOT_CONNECTED" | "CONNECTING" | "CONNECTED" | "CONNECTION_LOST"; +type ConnectionStateListener = (val: ConnectionStateEnum) => any; + +export class WebRTCStreamer { + pc: RTCPeerConnection; + ws: WebSocket; + state: StreamerState = "HELLO"; + + csListeners: ConnectionStateListener[] = []; + + constructor(stream: MediaStream) { + this.pc = new RTCPeerConnection({}); + this.pc.onconnectionstatechange = e => { + console.log("Connection state change: " + this.pc.connectionState); + this.csListeners.forEach(l => l(this.mapStateToConnectionState())); + }; + console.log("Stream tracks", stream.getAudioTracks()); + stream.getAudioTracks().forEach(track => this.pc.addTrack(track)); + this.ws = new WebSocket("ws://localhost:8079/stream"); // TODO + this.ws.onopen = e => { + console.log("WS open"); + this.csListeners.forEach(l => l(this.mapStateToConnectionState())); + }; + this.ws.onclose = e => { + console.log("WS close"); + this.csListeners.forEach(l => l(this.mapStateToConnectionState())); + }; + this.ws.addEventListener("message", this.onMessage.bind(this)); + } + + async onMessage(evt: MessageEvent) { + const data = JSON.parse(evt.data); + switch (data.kind) { + case "HELLO": + console.log("WS HELLO, our client ID is " + data.connectionId); + if (this.state !== "HELLO") { + this.ws.close(); + } + const offer = await this.pc.createOffer(); + // TODO do some fun SDP fuckery to get quality + await this.pc.setLocalDescription(offer); + await this.waitForIceCandidates(); + this.ws.send( + JSON.stringify({ + kind: "OFFER", + type: this.pc.localDescription!.type, + sdp: this.pc.localDescription!.sdp + }) + ); + this.state = "OFFER"; + break; + case "ANSWER": + const answer = new RTCSessionDescription({ + type: data.type, + sdp: data.sdp + }); + await this.pc.setRemoteDescription(answer); + this.state = "ANSWER"; + break; + } + } + + // TODO: supporting trickle ICE would be nICE + waitForIceCandidates() { + return new Promise(resolve => { + if (this.pc.iceGatheringState === "complete") { + resolve(); + } else { + const check = () => { + if (this.pc.iceGatheringState === "complete") { + this.pc.removeEventListener( + "icegatheringstatechange", + check + ); + resolve(); + } + }; + this.pc.addEventListener("icegatheringstatechange", check); + } + }); + } + + close() { + this.ws.close(); + this.pc.close(); + } + + mapStateToConnectionState(): ConnectionStateEnum { + switch (this.pc.connectionState) { + case "connected": return "CONNECTED"; + case "connecting": return "CONNECTING"; + case "disconnected": return "CONNECTION_LOST"; + case "failed": return "CONNECTION_LOST"; + default: + switch (this.ws.readyState) { + case 1: return "CONNECTING"; + case 2: case 3: return "CONNECTION_LOST"; + case 0: return "NOT_CONNECTED"; + default: throw new Error(); + } + } + } + + addConnectionStateListener(listener: ConnectionStateListener) { + this.csListeners.push(listener); + listener(this.mapStateToConnectionState()); + return () => { + this.csListeners.splice(this.csListeners.indexOf(listener), 1); + } + } +} diff --git a/src/broadcast/state.ts b/src/broadcast/state.ts index d7fbf39..1fe7629 100644 --- a/src/broadcast/state.ts +++ b/src/broadcast/state.ts @@ -8,22 +8,30 @@ import { } from "@reduxjs/toolkit"; import { AppThunk } from "../store"; import { Track, myradioApiRequest } from "../api"; +import { WebRTCStreamer, ConnectionStateEnum } from "./rtc_streamer"; +import { destination } from "../mixer/state"; +let streamer: WebRTCStreamer | null = null; interface BroadcastState { tracklisting: boolean; + connectionState: ConnectionStateEnum; } const broadcastState = createSlice({ name: "Broadcast", initialState: { - tracklisting: false + tracklisting: false, + connectionState: "NOT_CONNECTED" } as BroadcastState, reducers: { setTracklisting( state ) { state.tracklisting = !state.tracklisting; + }, + setConnectionState(state, action: PayloadAction) { + state.connectionState = action.payload; } } }); @@ -50,3 +58,10 @@ export const tracklistEnd = (trackid: number): AppThunk => (dispatch, getState) myradioApiRequest("/TracklistItem", "POST", { trackid: trackid, source: 'w', state: 'c' }); } }; + +export const connect = (): AppThunk => dispatch => { + streamer = new WebRTCStreamer(destination.stream); + streamer.addConnectionStateListener(state => { + dispatch(broadcastState.actions.setConnectionState(state)); + }); +}; diff --git a/src/mixer/state.ts b/src/mixer/state.ts index 276cb0b..e897786 100644 --- a/src/mixer/state.ts +++ b/src/mixer/state.ts @@ -29,10 +29,12 @@ let micSource: MediaStreamAudioSourceNode | null = null; let micGain: GainNode | null = null; let micCompressor: DynamicsCompressorNode | null = null; -// TODO -// const destination = audioContext.createWebcastSource(4096, 2); -const destination = audioContext.createDynamicsCompressor(); -destination.connect(audioContext.destination); +const finalCompressor = audioContext.createDynamicsCompressor(); +finalCompressor.connect(audioContext.destination); + +export const destination = audioContext.createMediaStreamDestination(); +console.log("final destination", destination); +finalCompressor.connect(destination); type PlayerStateEnum = "playing" | "paused" | "stopped"; type PlayerRepeatEnum = "none" | "one" | "all"; @@ -548,7 +550,7 @@ export const openMicrophone = (): AppThunk => async (dispatch, getState) => { micSource .connect(micGain) .connect(micCompressor) - .connect(destination); + .connect(finalCompressor); dispatch(mixerState.actions.micOpen()); }; diff --git a/src/showplanner/index.tsx b/src/showplanner/index.tsx index f8e4ce7..f5fa8c6 100644 --- a/src/showplanner/index.tsx +++ b/src/showplanner/index.tsx @@ -364,6 +364,9 @@ function NavBar() {
  • +
  • + +