├── .eslintrc.cjs ├── .gitignore ├── .prettierrc.json ├── .vscode └── extensions.json ├── LICENSE ├── Makefile ├── README.md ├── env.d.ts ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── public └── favicon.ico ├── src ├── App.vue ├── assets │ └── main.css ├── components │ ├── AccordionSection.vue │ ├── AddressBar.vue │ ├── AlertSection.vue │ ├── FooterSection.vue │ ├── HorizontalDivider.vue │ ├── NavBar.vue │ ├── SmallToggleGroup.vue │ ├── SpinnerIcon.vue │ └── StatusBadge.vue ├── lib │ └── wish │ │ ├── events.d.ts │ │ ├── events.ts │ │ ├── index.d.ts │ │ ├── index.ts │ │ ├── parser.d.ts │ │ └── parser.ts ├── main.ts ├── router │ └── index.ts ├── store │ ├── notification.ts │ └── setting.ts ├── types │ ├── global.d.ts │ └── level.d.ts └── views │ ├── 404View.vue │ ├── HomeView.vue │ ├── LiveView.vue │ └── WatchView.vue ├── tailwind.config.js ├── tsconfig.config.json ├── tsconfig.json └── vite.config.ts /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | require("@rushstack/eslint-patch/modern-module-resolution"); 3 | 4 | module.exports = { 5 | root: true, 6 | extends: [ 7 | "plugin:vue/vue3-essential", 8 | "eslint:recommended", 9 | "@vue/eslint-config-typescript", 10 | "@vue/eslint-config-prettier", 11 | ], 12 | parserOptions: { 13 | ecmaVersion: "latest", 14 | }, 15 | overrides: [ 16 | { 17 | files: ["*.config.js"], 18 | env: { 19 | node: true, 20 | }, 21 | }, 22 | ], 23 | }; 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .DS_Store 12 | dist 13 | dist-ssr 14 | coverage 15 | *.local 16 | 17 | /cypress/videos/ 18 | /cypress/screenshots/ 19 | 20 | # Editor directories and files 21 | .vscode/* 22 | !.vscode/extensions.json 23 | .idea 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"] 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Rachel Chen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build: 2 | npm run build 3 | 4 | guard-%: 5 | @ if [ -z '${${*}}' ]; then echo 'Environment variable $* not set' && exit 1; fi 6 | 7 | publish: guard-CLOUDFLARE_ACCOUNT_ID 8 | npx wrangler pages publish dist/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vue-wish 2 | 3 | This template should help get you started developing with Vue 3 in Vite. 4 | 5 | ## Recommended IDE Setup 6 | 7 | [VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin). 8 | 9 | ## Type Support for `.vue` Imports in TS 10 | 11 | TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types. 12 | 13 | If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps: 14 | 15 | 1. Disable the built-in TypeScript Extension 16 | 1. Run `Extensions: Show Built-in Extensions` from VSCode's command palette 17 | 2. Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)` 18 | 2. Reload the VSCode window by running `Developer: Reload Window` from the command palette. 19 | 20 | ## Customize configuration 21 | 22 | See [Vite Configuration Reference](https://vitejs.dev/config/). 23 | 24 | ## Project Setup 25 | 26 | ```sh 27 | npm install 28 | ``` 29 | 30 | ### Compile and Hot-Reload for Development 31 | 32 | ```sh 33 | npm run dev 34 | ``` 35 | 36 | ### Type-Check, Compile and Minify for Production 37 | 38 | ```sh 39 | npm run build 40 | ``` 41 | 42 | ### Lint with [ESLint](https://eslint.org/) 43 | 44 | ```sh 45 | npm run lint 46 | ``` 47 | -------------------------------------------------------------------------------- /env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | WebRTC Fun Time 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-wish", 3 | "version": "0.0.1", 4 | "scripts": { 5 | "dev": "vite", 6 | "build": "run-p type-check build-only", 7 | "preview": "vite preview", 8 | "build-only": "vite build", 9 | "type-check": "vue-tsc --noEmit", 10 | "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore" 11 | }, 12 | "dependencies": { 13 | "vue": "^3.2.41", 14 | "vue-router": "^4.1.5" 15 | }, 16 | "devDependencies": { 17 | "@headlessui/vue": "^1.7.4", 18 | "@heroicons/vue": "^2.0.13", 19 | "@rushstack/eslint-patch": "^1.1.4", 20 | "@tailwindcss/forms": "^0.5.3", 21 | "@types/node": "^16.11.68", 22 | "@types/webrtc": "^0.0.33", 23 | "@vitejs/plugin-vue": "^3.1.2", 24 | "@vue/eslint-config-prettier": "^7.0.0", 25 | "@vue/eslint-config-typescript": "^11.0.0", 26 | "@vue/tsconfig": "^0.1.3", 27 | "autoprefixer": "^10.4.13", 28 | "buffer": "^6.0.3", 29 | "eslint": "^8.22.0", 30 | "eslint-plugin-vue": "^9.3.0", 31 | "npm-run-all": "^4.1.5", 32 | "pinia": "^2.0.23", 33 | "pinia-plugin-persistedstate": "^2.3.0", 34 | "postcss": "^8.4.18", 35 | "prettier": "^2.7.1", 36 | "semantic-sdp": "^3.25.1", 37 | "tailwindcss": "^3.2.1", 38 | "typescript": "~4.7.4", 39 | "vite": "^3.1.8", 40 | "vue-tsc": "^1.0.8", 41 | "webrtc-adapter": "^8.2.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zllovesuki/vue-wish/7c6debc53f2f7b5ac9d3066a9d39d7e80f3fcc38/public/favicon.ico -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 66 | -------------------------------------------------------------------------------- /src/assets/main.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | audio::-webkit-media-controls-timeline, 6 | video::-webkit-media-controls-timeline { 7 | display: none !important; 8 | opacity: 0 !important; 9 | } 10 | -------------------------------------------------------------------------------- /src/components/AccordionSection.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 92 | -------------------------------------------------------------------------------- /src/components/AddressBar.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 58 | -------------------------------------------------------------------------------- /src/components/AlertSection.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 140 | -------------------------------------------------------------------------------- /src/components/FooterSection.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 64 | -------------------------------------------------------------------------------- /src/components/HorizontalDivider.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /src/components/NavBar.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 147 | -------------------------------------------------------------------------------- /src/components/SmallToggleGroup.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 57 | -------------------------------------------------------------------------------- /src/components/SpinnerIcon.vue: -------------------------------------------------------------------------------- 1 | 21 | -------------------------------------------------------------------------------- /src/components/StatusBadge.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 25 | -------------------------------------------------------------------------------- /src/lib/wish/events.d.ts: -------------------------------------------------------------------------------- 1 | interface StateEventMap { 2 | log: CustomEvent; 3 | status: CustomEvent; 4 | } 5 | interface StateEventTarget extends EventTarget { 6 | addEventListener( 7 | type: K, 8 | listener: (ev: StateEventMap[K]) => void, 9 | options?: boolean | AddEventListenerOptions 10 | ): void; 11 | addEventListener( 12 | type: string, 13 | callback: EventListenerOrEventListenerObject | null, 14 | options?: EventListenerOptions | boolean 15 | ): void; 16 | } 17 | export declare const TypedEventTarget: { 18 | new (): StateEventTarget; 19 | prototype: StateEventTarget; 20 | }; 21 | export interface LogEvent { 22 | message: string; 23 | } 24 | export interface StatusEvent { 25 | status: Status; 26 | } 27 | export declare type Status = "connected" | "disconnected"; 28 | export {}; 29 | -------------------------------------------------------------------------------- /src/lib/wish/events.ts: -------------------------------------------------------------------------------- 1 | interface StateEventMap { 2 | log: CustomEvent; 3 | status: CustomEvent; 4 | } 5 | 6 | interface StateEventTarget extends EventTarget { 7 | addEventListener( 8 | type: K, 9 | listener: (ev: StateEventMap[K]) => void, 10 | options?: boolean | AddEventListenerOptions 11 | ): void; 12 | addEventListener( 13 | type: string, 14 | callback: EventListenerOrEventListenerObject | null, 15 | options?: EventListenerOptions | boolean 16 | ): void; 17 | } 18 | 19 | export const TypedEventTarget = EventTarget as { 20 | new (): StateEventTarget; 21 | prototype: StateEventTarget; 22 | }; 23 | 24 | export interface LogEvent { 25 | message: string; 26 | } 27 | 28 | export interface StatusEvent { 29 | status: Status; 30 | } 31 | 32 | export type Status = "connected" | "disconnected"; 33 | -------------------------------------------------------------------------------- /src/lib/wish/index.d.ts: -------------------------------------------------------------------------------- 1 | import { TypedEventTarget } from "./events"; 2 | export declare const DEFAULT_ICE_SERVERS: string[]; 3 | export declare const TRICKLE_BATCH_INTERVAL: number; 4 | export declare class WISH extends TypedEventTarget { 5 | private peerConnection?; 6 | private iceServers; 7 | private videoSender?; 8 | private remoteTracks; 9 | private playerMedia?; 10 | private connecting; 11 | private connectedPromise; 12 | private connectedResolver; 13 | private connectedRejector; 14 | private gatherPromise; 15 | private gatherResolver; 16 | private endpoint?; 17 | private resourceURL?; 18 | private mode; 19 | private parsedOffer?; 20 | private useTrickle; 21 | private etag?; 22 | private providedIceServer?; 23 | private trickleBatchingJob?; 24 | private batchedCandidates; 25 | constructor(iceServers?: string[]); 26 | private logMessage; 27 | private killConnection; 28 | private createConnection; 29 | private newResolvers; 30 | private addEventListeners; 31 | private onGatheringStateChange; 32 | private onConnectionStateChange; 33 | private onICECandidate; 34 | private startTrickleBatching; 35 | private stopTrickleBatching; 36 | private trickleBatch; 37 | private onSignalingStateChange; 38 | private onICEConnectionStateChange; 39 | private onTrack; 40 | private waitForICEGather; 41 | private doSignaling; 42 | private whipOffer; 43 | private whepClientOffer; 44 | private doSignalingPOST; 45 | private doSignalingPATCH; 46 | private checkEndpoint; 47 | WithEndpoint(endpoint: string, trickle: boolean): Promise; 48 | Disconnect(): Promise; 49 | Play(): Promise; 50 | Publish(src: MediaStream): Promise; 51 | ReplaceVideoTrack(src: MediaStream): Promise; 52 | } 53 | -------------------------------------------------------------------------------- /src/lib/wish/index.ts: -------------------------------------------------------------------------------- 1 | import adapter from "webrtc-adapter"; 2 | import { CandidateInfo, SDPInfo } from "semantic-sdp"; 3 | import { TypedEventTarget, type StatusEvent, type LogEvent } from "./events"; 4 | import { parserLinkHeader } from "./parser"; 5 | 6 | export const DEFAULT_ICE_SERVERS = [ 7 | "stun:stun.cloudflare.com:3478", 8 | "stun:stun.l.google.com:19302", 9 | ]; 10 | 11 | export const TRICKLE_BATCH_INTERVAL = 50; 12 | 13 | enum Mode { 14 | Player = "player", 15 | Publisher = "publisher", 16 | } 17 | 18 | export class WISH extends TypedEventTarget { 19 | private peerConnection?: RTCPeerConnection; 20 | private iceServers: string[] = DEFAULT_ICE_SERVERS; 21 | 22 | private videoSender?: RTCRtpSender; 23 | 24 | private remoteTracks: MediaStreamTrack[] = []; 25 | private playerMedia?: MediaStream; 26 | 27 | private connecting: boolean = false; 28 | private connectedPromise!: Promise; 29 | private connectedResolver!: (any: void) => void; 30 | private connectedRejector!: (reason?: any) => void; 31 | private gatherPromise!: Promise; 32 | private gatherResolver!: (any: void) => void; 33 | 34 | private endpoint?: string; 35 | private resourceURL?: string; 36 | private mode: Mode = Mode.Player; 37 | private parsedOffer?: SDPInfo; 38 | private useTrickle: boolean = false; 39 | private etag?: string; 40 | 41 | private trickleBatchingJob?: number; 42 | private batchedCandidates: RTCIceCandidate[] = []; 43 | 44 | private connectStartTime?: number; 45 | private iceStartTime?: number; 46 | 47 | constructor(iceServers?: string[]) { 48 | super(); 49 | if (iceServers) { 50 | this.iceServers = iceServers ? iceServers : DEFAULT_ICE_SERVERS; 51 | } 52 | this.logMessage( 53 | `Enabling webrtc-adapter for ${adapter.browserDetails.browser}@${adapter.browserDetails.version}` 54 | ); 55 | this.newResolvers(); 56 | } 57 | 58 | private logMessage(str: string) { 59 | const now = new Date().toLocaleString(); 60 | console.log(`${now}: ${str}`); 61 | this.dispatchEvent( 62 | new CustomEvent("log", { 63 | detail: { 64 | message: str, 65 | }, 66 | }) 67 | ); 68 | } 69 | 70 | private killConnection() { 71 | if (this.peerConnection) { 72 | this.logMessage("Closing RTCPeerConnection"); 73 | this.peerConnection.close(); 74 | this.peerConnection = undefined; 75 | this.parsedOffer = undefined; 76 | this.playerMedia = undefined; 77 | this.videoSender = undefined; 78 | this.connecting = false; 79 | this.remoteTracks = []; 80 | this.batchedCandidates = []; 81 | this.stopTrickleBatching(); 82 | } 83 | } 84 | 85 | private createConnection() { 86 | this.logMessage("Creating a new RTCPeerConnection"); 87 | this.peerConnection = new RTCPeerConnection({ 88 | iceServers: [{ urls: this.iceServers }], 89 | }); 90 | if (!this.peerConnection) { 91 | throw new Error("Failed to create a new RTCPeerConnection"); 92 | } 93 | this.addEventListeners(); 94 | this.newResolvers(); 95 | } 96 | 97 | private newResolvers() { 98 | this.connectedPromise = new Promise((resolve, reject) => { 99 | this.connectedResolver = resolve; 100 | this.connectedRejector = reject; 101 | }); 102 | this.gatherPromise = new Promise((resolve) => { 103 | this.gatherResolver = resolve; 104 | }); 105 | } 106 | 107 | private addEventListeners() { 108 | if (!this.peerConnection) { 109 | return; 110 | } 111 | this.peerConnection.addEventListener( 112 | "connectionstatechange", 113 | this.onConnectionStateChange.bind(this) 114 | ); 115 | this.peerConnection.addEventListener( 116 | "iceconnectionstatechange", 117 | this.onICEConnectionStateChange.bind(this) 118 | ); 119 | this.peerConnection.addEventListener( 120 | "icegatheringstatechange", 121 | this.onGatheringStateChange.bind(this) 122 | ); 123 | this.peerConnection.addEventListener( 124 | "icecandidate", 125 | this.onICECandidate.bind(this) 126 | ); 127 | this.peerConnection.addEventListener("track", this.onTrack.bind(this)); 128 | this.peerConnection.addEventListener( 129 | "signalingstatechange", 130 | this.onSignalingStateChange.bind(this) 131 | ); 132 | } 133 | 134 | private onGatheringStateChange() { 135 | if (!this.peerConnection) { 136 | return; 137 | } 138 | this.logMessage( 139 | `ICE Gathering State changed: ${this.peerConnection.iceGatheringState}` 140 | ); 141 | switch (this.peerConnection.iceGatheringState) { 142 | case "complete": 143 | this.gatherResolver(); 144 | break; 145 | } 146 | } 147 | 148 | private onConnectionStateChange() { 149 | if (!this.peerConnection) { 150 | return; 151 | } 152 | this.logMessage( 153 | `Peer Connection State changed: ${this.peerConnection.connectionState}` 154 | ); 155 | const transportHandler = ( 156 | track: MediaStreamTrack, 157 | transport: RTCDtlsTransport 158 | ) => { 159 | const ice = transport.iceTransport; 160 | if (!ice) { 161 | return; 162 | } 163 | const pair = ice.getSelectedCandidatePair(); 164 | if (!pair) { 165 | return; 166 | } 167 | if (pair.local && pair.remote) { 168 | this.logMessage( 169 | `[${track.kind}] Selected Candidate: (local ${pair.local.address})-(remote ${pair.remote.candidate})` 170 | ); 171 | } 172 | }; 173 | switch (this.peerConnection.connectionState) { 174 | case "connected": 175 | switch (this.mode) { 176 | case Mode.Player: 177 | for (const receiver of this.peerConnection.getReceivers()) { 178 | const transport = receiver.transport; 179 | if (!transport) { 180 | continue; 181 | } 182 | transportHandler(receiver.track, transport); 183 | } 184 | break; 185 | case Mode.Publisher: 186 | for (const sender of this.peerConnection.getSenders()) { 187 | const transport = sender.transport; 188 | if (!transport) { 189 | continue; 190 | } 191 | if (!sender.track) { 192 | continue; 193 | } 194 | if (sender.track.kind === "video") { 195 | this.videoSender = sender; 196 | } 197 | transportHandler(sender.track, transport); 198 | } 199 | break; 200 | } 201 | break; 202 | case "failed": 203 | this.dispatchEvent( 204 | new CustomEvent("status", { 205 | detail: { 206 | status: "disconnected", 207 | }, 208 | }) 209 | ); 210 | break; 211 | } 212 | } 213 | 214 | private onICECandidate(ev: RTCPeerConnectionIceEvent) { 215 | if (ev.candidate) { 216 | const candidate = ev.candidate; 217 | if (!candidate.candidate) { 218 | return; 219 | } 220 | this.logMessage( 221 | `Got ICE candidate: ${candidate.candidate.replace("candidate:", "")}` 222 | ); 223 | if (!this.parsedOffer) { 224 | return; 225 | } 226 | if (!this.useTrickle) { 227 | return; 228 | } 229 | if (candidate.candidate.includes(".local")) { 230 | this.logMessage("Skipping mDNS candidate for trickle ICE"); 231 | return; 232 | } 233 | this.batchedCandidates.push(candidate); 234 | } else { 235 | this.logMessage(`End of ICE candidates`); 236 | } 237 | } 238 | 239 | private startTrickleBatching() { 240 | if (this.trickleBatchingJob) { 241 | clearInterval(this.trickleBatchingJob); 242 | } 243 | this.logMessage( 244 | `Starting batching job to trickle candidates every ${TRICKLE_BATCH_INTERVAL}ms` 245 | ); 246 | this.trickleBatchingJob = setInterval( 247 | this.trickleBatch.bind(this), 248 | TRICKLE_BATCH_INTERVAL 249 | ); 250 | } 251 | 252 | private stopTrickleBatching() { 253 | if (!this.trickleBatchingJob) { 254 | return; 255 | } 256 | this.logMessage("Stopping trickle batching job"); 257 | clearInterval(this.trickleBatchingJob); 258 | this.trickleBatchingJob = undefined; 259 | } 260 | 261 | private async trickleBatch() { 262 | if (!this.parsedOffer) { 263 | return; 264 | } 265 | if (!this.batchedCandidates.length) { 266 | return; 267 | } 268 | 269 | const fragSDP = new SDPInfo(); 270 | const candidates = this.batchedCandidates.splice(0); 271 | this.logMessage(`Tricking with ${candidates.length} candidates`); 272 | 273 | for (const candidate of candidates) { 274 | const candidateObject = CandidateInfo.expand({ 275 | foundation: candidate.foundation || "", 276 | componentId: candidate.component === "rtp" ? 1 : 2, 277 | transport: candidate.protocol || "udp", 278 | priority: candidate.priority || 0, 279 | address: candidate.address || "", 280 | port: candidate.port || 0, 281 | type: candidate.type || "host", 282 | relAddr: candidate.relatedAddress || undefined, 283 | relPort: 284 | typeof candidate.relatedPort !== "undefined" && 285 | candidate.relatedPort !== null 286 | ? candidate.relatedPort.toString() 287 | : undefined, 288 | }); 289 | fragSDP.addCandidate(candidateObject); 290 | } 291 | fragSDP.setICE(this.parsedOffer.getICE()); 292 | 293 | const generated = fragSDP.toIceFragmentString(); 294 | // for trickle-ice-sdpfrag, we need a psuedo m= line 295 | const lines = generated.split(/\r?\n/); 296 | lines.splice(2, 0, "m=audio 9 RTP/AVP 0"); 297 | lines.splice(3, 0, "a=mid:0"); 298 | const frag = lines.join("\r\n"); 299 | try { 300 | await this.doSignalingPATCH(frag, false); 301 | } catch (e) { 302 | this.logMessage(`Failed to trickle: ${(e as Error).message}`); 303 | } 304 | } 305 | 306 | private onSignalingStateChange() { 307 | if (!this.peerConnection) { 308 | return; 309 | } 310 | this.logMessage( 311 | `Signaling State changed: ${this.peerConnection.signalingState}` 312 | ); 313 | } 314 | 315 | private onICEConnectionStateChange() { 316 | if (!this.peerConnection) { 317 | return; 318 | } 319 | this.logMessage( 320 | `ICE Connection State changed: ${this.peerConnection.iceConnectionState}` 321 | ); 322 | switch (this.peerConnection.iceConnectionState) { 323 | case "checking": 324 | this.iceStartTime = performance.now(); 325 | break; 326 | case "connected": 327 | const connected = performance.now(); 328 | if (this.connectStartTime) { 329 | const delta = connected - this.connectStartTime; 330 | this.logMessage( 331 | `Took ${(delta / 1000).toFixed( 332 | 2 333 | )} seconds to establish PeerConnection (end-to-end)` 334 | ); 335 | } 336 | if (this.iceStartTime) { 337 | const delta = connected - this.iceStartTime; 338 | this.logMessage( 339 | `Took ${(delta / 1000).toFixed( 340 | 2 341 | )} seconds to establish PeerConnection (ICE)` 342 | ); 343 | } 344 | this.dispatchEvent( 345 | new CustomEvent("status", { 346 | detail: { 347 | status: "connected", 348 | }, 349 | }) 350 | ); 351 | this.connecting = false; 352 | this.connectedResolver(); 353 | this.stopTrickleBatching(); 354 | break; 355 | case "failed": 356 | if (this.connecting) { 357 | this.connectedRejector("ICE failed while trying to connect"); 358 | this.stopTrickleBatching(); 359 | this.connecting = false; 360 | } 361 | break; 362 | } 363 | } 364 | 365 | private onTrack(ev: RTCTrackEvent) { 366 | if (this.mode !== Mode.Player) { 367 | return; 368 | } 369 | this.remoteTracks.push(ev.track); 370 | 371 | if (this.remoteTracks.length === 2) { 372 | for (const track of this.remoteTracks) { 373 | this.logMessage(`Got remote ${track.kind} track`); 374 | if (this.playerMedia) { 375 | this.playerMedia.addTrack(track); 376 | } 377 | } 378 | } 379 | } 380 | 381 | private async waitForICEGather() { 382 | setTimeout(() => { 383 | this.gatherResolver(); 384 | }, 1000); 385 | await this.gatherPromise; 386 | } 387 | 388 | private async doSignaling() { 389 | if (!this.peerConnection) { 390 | return; 391 | } 392 | this.connectStartTime = performance.now(); 393 | const localOffer = await this.peerConnection.createOffer(); 394 | if (!localOffer.sdp) { 395 | throw new Error("Fail to create offer"); 396 | } 397 | 398 | this.parsedOffer = SDPInfo.parse(localOffer.sdp); 399 | let remoteOffer: string = ""; 400 | 401 | if (!this.useTrickle) { 402 | await this.peerConnection.setLocalDescription(localOffer); 403 | await this.waitForICEGather(); 404 | const offer = this.peerConnection.localDescription; 405 | if (!offer) { 406 | throw new Error("no LocalDescription"); 407 | } 408 | remoteOffer = await this.doSignalingPOST(offer.sdp); 409 | } else { 410 | // ensure that resourceURL is set before trickle happens 411 | remoteOffer = await this.doSignalingPOST(localOffer.sdp, true); 412 | this.startTrickleBatching(); 413 | await this.peerConnection.setLocalDescription(localOffer); 414 | } 415 | await this.peerConnection.setRemoteDescription({ 416 | sdp: remoteOffer, 417 | type: "answer", 418 | }); 419 | this.connecting = true; 420 | } 421 | 422 | private setVideoCodecPreference(transceiver: RTCRtpTransceiver) { 423 | if ( 424 | typeof RTCRtpSender.getCapabilities === "undefined" || 425 | typeof transceiver.setCodecPreferences === "undefined" 426 | ) { 427 | return; 428 | } 429 | const capability = RTCRtpSender.getCapabilities("video"); 430 | const codecs = capability ? capability.codecs : []; 431 | this.logMessage( 432 | `Available codecs for outbound video: ${codecs 433 | .map((c) => c.mimeType) 434 | .join(", ")}` 435 | ); 436 | for (let i = 0; i < codecs.length; i++) { 437 | const codec = codecs[i]; 438 | if (codec.mimeType === "video/VP9") { 439 | codecs.unshift(codecs.splice(i, 1)[0]); 440 | } 441 | } 442 | transceiver.setCodecPreferences(codecs); 443 | } 444 | 445 | private async whipOffer(src: MediaStream) { 446 | if (!this.peerConnection) { 447 | return; 448 | } 449 | for (const track of src.getTracks()) { 450 | this.logMessage(`Adding local ${track.kind} track`); 451 | const transceiver = this.peerConnection.addTransceiver(track, { 452 | direction: "sendonly", 453 | }); 454 | if (track.kind === "video") { 455 | this.setVideoCodecPreference(transceiver); 456 | } 457 | } 458 | await this.doSignaling(); 459 | } 460 | 461 | private async whepClientOffer() { 462 | if (!this.peerConnection) { 463 | return; 464 | } 465 | this.peerConnection.addTransceiver("video", { 466 | direction: "recvonly", 467 | }); 468 | this.peerConnection.addTransceiver("audio", { 469 | direction: "recvonly", 470 | }); 471 | await this.doSignaling(); 472 | } 473 | 474 | private updateETag(resp: Response) { 475 | const etag = resp.headers.get("etag"); 476 | if (etag) { 477 | try { 478 | this.etag = JSON.parse(etag); 479 | } catch (e) { 480 | this.logMessage("Failed to parse ETag header for PATCH"); 481 | } 482 | } 483 | if (this.etag) { 484 | this.logMessage(`Got ${this.etag} as ETag`); 485 | } 486 | } 487 | 488 | private async doSignalingPOST( 489 | sdp: string, 490 | useLink?: boolean 491 | ): Promise { 492 | if (!this.endpoint) { 493 | throw new Error("No WHIP/WHEP endpoint has been set"); 494 | } 495 | const signalStartTime = performance.now(); 496 | const resp = await fetch(this.endpoint, { 497 | method: "POST", 498 | mode: "cors", 499 | body: sdp, 500 | headers: { 501 | "content-type": "application/sdp", 502 | }, 503 | }); 504 | const body = await resp.text(); 505 | if (resp.status != 201) { 506 | throw new Error(`Unexpected status code ${resp.status}: ${body}`); 507 | } 508 | 509 | const resource = resp.headers.get("location"); 510 | if (resource) { 511 | if (resource.startsWith("http")) { 512 | // absolute path 513 | this.resourceURL = resource; 514 | } else { 515 | // relative path 516 | const parsed = new URL(this.endpoint); 517 | parsed.pathname = resource; 518 | this.resourceURL = parsed.toString(); 519 | } 520 | this.logMessage(`Using ${this.resourceURL} as WHIP/WHEP Resource URL`); 521 | } else { 522 | this.logMessage("No Location header in response"); 523 | } 524 | 525 | this.updateETag(resp); 526 | 527 | if (resp.headers.get("accept-post") || resp.headers.get("accept-patch")) { 528 | switch (this.mode) { 529 | case Mode.Publisher: 530 | this.logMessage( 531 | `WHIP version draft-ietf-wish-whip-05 (Accept-Post/Accept-Patch)` 532 | ); 533 | break; 534 | case Mode.Player: 535 | this.logMessage( 536 | `WHEP version draft-murillo-whep-01 (Accept-Post/Accept-Patch)` 537 | ); 538 | break; 539 | } 540 | } 541 | 542 | if (this.peerConnection && useLink) { 543 | const link = resp.headers.get("link"); 544 | if (link) { 545 | const links = parserLinkHeader(link); 546 | if (links["ice-server"]) { 547 | const url = links["ice-server"].url; 548 | this.logMessage(`Endpoint provided ice-server ${url}`); 549 | this.peerConnection.setConfiguration({ 550 | iceServers: [ 551 | { 552 | urls: [url], 553 | }, 554 | ], 555 | }); 556 | } 557 | } 558 | } 559 | 560 | const signaled = performance.now(); 561 | const delta = signaled - signalStartTime; 562 | this.logMessage( 563 | `Took ${(delta / 1000).toFixed(2)} seconds to exchange SDP` 564 | ); 565 | 566 | return body; 567 | } 568 | 569 | private async doSignalingPATCH(frag: string, iceRestart: boolean) { 570 | if (!this.resourceURL) { 571 | throw new Error("No resource URL"); 572 | } 573 | const headers: HeadersInit = { 574 | "content-type": "application/trickle-ice-sdpfrag", 575 | }; 576 | if (this.etag) { 577 | headers["if-match"] = this.etag; 578 | } 579 | const resp = await fetch(this.resourceURL, { 580 | method: "PATCH", 581 | mode: "cors", 582 | body: frag, 583 | headers, 584 | }); 585 | switch (resp.status) { 586 | case 200: 587 | if (iceRestart) { 588 | this.updateETag(resp); 589 | return; 590 | } 591 | // if we are doing an ice restart, we expect 200 OK 592 | break; 593 | case 204: 594 | if (!iceRestart) { 595 | return; 596 | } 597 | // if we are doing trickle ice, we expect 204 No Content 598 | break; 599 | case 405: 600 | case 501: 601 | this.logMessage("Trickle ICE not supported, disabling"); 602 | this.useTrickle = false; 603 | break; 604 | case 412: 605 | this.logMessage("Resource returns 412, session is outdated"); 606 | this.useTrickle = false; 607 | break; 608 | } 609 | const body = await resp.text(); 610 | throw new Error(`Unexpected status code ${resp.status}: ${body}`); 611 | } 612 | 613 | async WithEndpoint(endpoint: string, trickle: boolean) { 614 | if (endpoint === "") { 615 | throw new Error("Endpoint cannot be empty"); 616 | } 617 | try { 618 | const parsed = new URL(endpoint); 619 | this.logMessage(`Using ${parsed.toString()} as the WHIP/WHEP Endpoint`); 620 | this.useTrickle = trickle; 621 | this.logMessage(`${trickle ? "Enabling" : "Disabling"} trickle ICE`); 622 | } catch (e) { 623 | throw new Error("Invalid Endpoint URL"); 624 | } 625 | this.endpoint = endpoint; 626 | this.resourceURL = ""; 627 | } 628 | 629 | async Disconnect() { 630 | this.endpoint = ""; 631 | this.killConnection(); 632 | if (!this.resourceURL) { 633 | throw new Error("No resource URL"); 634 | } 635 | const resp = await fetch(this.resourceURL, { 636 | method: "DELETE", 637 | mode: "cors", 638 | }); 639 | if (resp.status != 200) { 640 | const body = await resp.text(); 641 | throw new Error(`Unexpected status code ${resp.status}: ${body}`); 642 | } 643 | this.logMessage(`----- Disconnected via DELETE -----`); 644 | this.resourceURL = ""; 645 | } 646 | 647 | async Play(): Promise { 648 | this.mode = Mode.Player; 649 | this.killConnection(); 650 | this.playerMedia = new MediaStream(); 651 | this.createConnection(); 652 | await this.whepClientOffer(); 653 | await this.connectedPromise; 654 | return this.playerMedia; 655 | } 656 | 657 | async Publish(src: MediaStream) { 658 | this.mode = Mode.Publisher; 659 | this.killConnection(); 660 | this.createConnection(); 661 | await this.whipOffer(src); 662 | await this.connectedPromise; 663 | } 664 | 665 | async ReplaceVideoTrack(src: MediaStream) { 666 | if (!this.videoSender) { 667 | throw new Error("Publisher is not active"); 668 | } 669 | const tracks = src.getTracks(); 670 | if (tracks.length < 1) { 671 | throw new Error("No tracks in MediaStream"); 672 | } 673 | return await this.videoSender.replaceTrack(tracks[0]); 674 | } 675 | } 676 | -------------------------------------------------------------------------------- /src/lib/wish/parser.d.ts: -------------------------------------------------------------------------------- 1 | export interface Link { 2 | rel: string; 3 | url: string; 4 | [key: string]: string; 5 | } 6 | export interface Links { 7 | [key: string]: Link; 8 | } 9 | export declare function parserLinkHeader(links: string): Links; 10 | export {}; 11 | -------------------------------------------------------------------------------- /src/lib/wish/parser.ts: -------------------------------------------------------------------------------- 1 | // adopted from https://github.com/thlorenz/parse-link-header 2 | function parseLink(link: string): Link | null { 3 | const matches = link.match(/]*)>(.*)/); 4 | if (!matches) { 5 | return null; 6 | } 7 | try { 8 | const linkUrl = matches[1]; 9 | const parts = matches[2].split(";"); 10 | const parsedUrl = new URL(linkUrl); 11 | const qs = parsedUrl.searchParams; 12 | 13 | parts.shift(); 14 | 15 | const initial: Link = { rel: "", url: linkUrl }; 16 | const reduced = parts.reduce((acc: Link, p) => { 17 | const m = p.match(/\s*(.+)\s*=\s*"?([^"]+)"?/); 18 | if (m) { 19 | acc[m[1]] = m[2]; 20 | } 21 | return acc; 22 | }, initial); 23 | 24 | if (!reduced.rel) { 25 | return null; 26 | } 27 | 28 | qs.forEach((v, k) => { 29 | reduced[k] = v; 30 | }); 31 | 32 | return reduced; 33 | } catch (e) { 34 | return null; 35 | } 36 | } 37 | 38 | // https://stackoverflow.com/a/46700791 39 | function notEmpty(value: T | null | undefined): value is T { 40 | if (value === null || value === undefined) return false; 41 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 42 | const testDummy: T = value; 43 | return true; 44 | } 45 | 46 | export interface Link { 47 | rel: string; 48 | url: string; 49 | [key: string]: string; 50 | } 51 | 52 | export interface Links { 53 | [key: string]: Link; 54 | } 55 | 56 | export function parserLinkHeader(links: string): Links { 57 | return links 58 | .split(/,\s* { 62 | links[l.rel] = l; 63 | return links; 64 | }, {} as Links); 65 | } 66 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | window.global ||= window; 2 | import { createApp, watchEffect, h } from "vue"; 3 | import { createPinia } from "pinia"; 4 | import piniaPluginPersistedstate from "pinia-plugin-persistedstate"; 5 | import { useSettingStore } from "./store/setting"; 6 | 7 | import App from "./App.vue"; 8 | import router from "./router"; 9 | 10 | const pinia = createPinia(); 11 | pinia.use(piniaPluginPersistedstate); 12 | 13 | const app = createApp({ 14 | setup() { 15 | const setting = useSettingStore(); 16 | const applyDarkMode = () => 17 | document.documentElement.classList[setting.darkMode ? "add" : "remove"]( 18 | "dark" 19 | ); 20 | watchEffect(applyDarkMode); 21 | }, 22 | render: () => h(App), 23 | }); 24 | 25 | app.use(router); 26 | app.use(pinia); 27 | 28 | app.mount("#app"); 29 | -------------------------------------------------------------------------------- /src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from "vue-router"; 2 | import HomeView from "@/views/HomeView.vue"; 3 | 4 | const router = createRouter({ 5 | history: createWebHistory(import.meta.env.BASE_URL), 6 | routes: [ 7 | { 8 | path: "/", 9 | name: "home", 10 | component: HomeView, 11 | }, 12 | { 13 | path: "/watch", 14 | name: "watch", 15 | component: () => import("@/views/WatchView.vue"), 16 | }, 17 | { 18 | path: "/live", 19 | name: "live", 20 | component: () => import("@/views/LiveView.vue"), 21 | }, 22 | { 23 | path: "/:pathMatch(.*)", 24 | name: "not-found", 25 | component: () => import("@/views/404View.vue"), 26 | }, 27 | ], 28 | }); 29 | 30 | export default router; 31 | -------------------------------------------------------------------------------- /src/store/notification.ts: -------------------------------------------------------------------------------- 1 | import { ref } from "vue"; 2 | import { defineStore } from "pinia"; 3 | 4 | export const useNotificationStore = defineStore("notification", () => { 5 | const show = ref(false); 6 | const message = ref(""); 7 | 8 | const notify = (msg: string) => { 9 | message.value = msg; 10 | show.value = true; 11 | }; 12 | 13 | return { 14 | show, 15 | message, 16 | notify, 17 | }; 18 | }); 19 | -------------------------------------------------------------------------------- /src/store/setting.ts: -------------------------------------------------------------------------------- 1 | import { ref } from "vue"; 2 | import { defineStore } from "pinia"; 3 | 4 | export const useSettingStore = defineStore( 5 | "setting", 6 | () => { 7 | let dark = false; 8 | if ( 9 | window.matchMedia && 10 | window.matchMedia("(prefers-color-scheme: dark)").matches 11 | ) { 12 | dark = true; 13 | } 14 | const trickle = ref(true); 15 | const darkMode = ref(dark); 16 | 17 | const lastLive = ref(""); 18 | 19 | return { 20 | trickle, 21 | darkMode, 22 | lastLive, 23 | }; 24 | }, 25 | { 26 | persist: true, 27 | } 28 | ); 29 | -------------------------------------------------------------------------------- /src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | 3 | declare global { 4 | interface Window { 5 | global: any; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/types/level.d.ts: -------------------------------------------------------------------------------- 1 | export type AlertLevel = "fail" | "success" | "info"; 2 | -------------------------------------------------------------------------------- /src/views/404View.vue: -------------------------------------------------------------------------------- 1 | 37 | -------------------------------------------------------------------------------- /src/views/HomeView.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 22 | -------------------------------------------------------------------------------- /src/views/LiveView.vue: -------------------------------------------------------------------------------- 1 | 360 | 361 | 642 | -------------------------------------------------------------------------------- /src/views/WatchView.vue: -------------------------------------------------------------------------------- 1 | 119 | 120 | 201 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | const defaultTheme = require("tailwindcss/defaultTheme"); 3 | module.exports = { 4 | darkMode: "class", 5 | content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"], 6 | theme: { 7 | extend: { 8 | fontFamily: { 9 | sans: ["Inter var", ...defaultTheme.fontFamily.sans], 10 | }, 11 | }, 12 | }, 13 | plugins: [require("@tailwindcss/forms")], 14 | }; 15 | -------------------------------------------------------------------------------- /tsconfig.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.node.json", 3 | "include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "playwright.config.*"], 4 | "compilerOptions": { 5 | "composite": true, 6 | "types": ["node"] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.web.json", 3 | "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], 4 | "compilerOptions": { 5 | "baseUrl": ".", 6 | "paths": { 7 | "@/*": ["./src/*"] 8 | }, 9 | "types": [ 10 | "@types/webrtc" 11 | ] 12 | }, 13 | 14 | "references": [ 15 | { 16 | "path": "./tsconfig.config.json" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from "node:url"; 2 | 3 | import { defineConfig } from "vite"; 4 | import vue from "@vitejs/plugin-vue"; 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | plugins: [vue()], 9 | resolve: { 10 | alias: { 11 | "@": fileURLToPath(new URL("./src", import.meta.url)), 12 | }, 13 | }, 14 | }); 15 | --------------------------------------------------------------------------------