diff --git a/lib/moq-publisher/index.ts b/lib/moq-publisher/index.ts new file mode 100644 index 0000000..cae8416 --- /dev/null +++ b/lib/moq-publisher/index.ts @@ -0,0 +1,160 @@ +// src/components/publisher-moq.ts + +import STYLE_SHEET from "./publisher-moq.css" +import { PublisherApi, PublisherOptions } from "../publish" + +export class PublisherMoq extends HTMLElement { + private shadow: ShadowRoot + private cameraSelect!: HTMLSelectElement + private microphoneSelect!: HTMLSelectElement + private previewVideo!: HTMLVideoElement + private connectButton!: HTMLButtonElement + private mediaStream: MediaStream | null = null + + private publisher?: PublisherApi + private isPublishing = false + + constructor() { + super() + this.shadow = this.attachShadow({ mode: "open" }) + + // CSS + const style = document.createElement("style") + style.textContent = STYLE_SHEET + this.shadow.appendChild(style) + + const container = document.createElement("div") + container.classList.add("publisher-container") + + this.cameraSelect = document.createElement("select") + this.microphoneSelect = document.createElement("select") + this.previewVideo = document.createElement("video") + this.connectButton = document.createElement("button") + + this.previewVideo.autoplay = true + this.previewVideo.playsInline = true + this.previewVideo.muted = true + this.connectButton.textContent = "Connect" + + container.append(this.cameraSelect, this.microphoneSelect, this.previewVideo, this.connectButton) + this.shadow.appendChild(container) + + // Bindings + this.handleDeviceChange = this.handleDeviceChange.bind(this) + this.handleClick = this.handleClick.bind(this) + + // Listeners + navigator.mediaDevices.addEventListener("devicechange", this.handleDeviceChange) + this.cameraSelect.addEventListener("change", () => this.startPreview()) + this.microphoneSelect.addEventListener("change", () => this.startPreview()) + this.connectButton.addEventListener("click", this.handleClick) + } + + connectedCallback() { + this.populateDeviceLists() + } + + disconnectedCallback() { + navigator.mediaDevices.removeEventListener("devicechange", this.handleDeviceChange) + } + + private async handleDeviceChange() { + await this.populateDeviceLists() + } + + private async populateDeviceLists() { + const devices = await navigator.mediaDevices.enumerateDevices() + const vids = devices.filter((d) => d.kind === "videoinput") + const mics = devices.filter((d) => d.kind === "audioinput") + + this.cameraSelect.innerHTML = "" + this.microphoneSelect.innerHTML = "" + + vids.forEach((d) => { + const o = document.createElement("option") + o.value = d.deviceId + o.textContent = d.label || `Camera ${this.cameraSelect.length + 1}` + this.cameraSelect.append(o) + }) + mics.forEach((d) => { + const o = document.createElement("option") + o.value = d.deviceId + o.textContent = d.label || `Mic ${this.microphoneSelect.length + 1}` + this.microphoneSelect.append(o) + }) + + await this.startPreview() + } + + private async startPreview() { + const vidId = this.cameraSelect.value + const micId = this.microphoneSelect.value + if (this.mediaStream) { + this.mediaStream.getTracks().forEach((t) => t.stop()) + } + this.mediaStream = await navigator.mediaDevices.getUserMedia({ + video: vidId ? { deviceId: { exact: vidId } } : true, + audio: micId ? { deviceId: { exact: micId } } : true, + }) + + this.previewVideo.srcObject = this.mediaStream + } + + private async handleClick() { + if (!this.isPublishing) { + if (!this.mediaStream) { + console.warn("No media stream available") + return + } + + const audioTrack = this.mediaStream!.getAudioTracks()[0]; + const settings = audioTrack.getSettings(); + + const sampleRate = settings.sampleRate ?? (await new AudioContext()).sampleRate; + const numberOfChannels = settings.channelCount ?? 2; + + const videoConfig: VideoEncoderConfig = {codec: "avc1.42E01E", width: this.previewVideo.videoWidth, height: this.previewVideo.videoHeight, bitrate:1000000, framerate: 60}; + const audioConfig: AudioEncoderConfig = {codec: "opus", sampleRate, numberOfChannels, bitrate:64000}; + + + const opts: PublisherOptions = { + url: this.getAttribute("src")!, + fingerprintUrl: this.getAttribute("fingerprint")!, + namespace: [ + this.getAttribute("namespace")! || crypto.randomUUID() + ], + media: this.mediaStream, + video: videoConfig, + audio: audioConfig, + } + + console.log("Publisher Options", opts) + + this.publisher = new PublisherApi(opts) + + try { + await this.publisher.publish() + this.isPublishing = true + this.connectButton.textContent = "Stop" + this.cameraSelect.disabled = true + this.microphoneSelect.disabled = true + } catch (err) { + console.error("Publish failed:", err) + } + } else { + try { + await this.publisher!.stop() + } catch (err) { + console.error("Stop failed:", err) + } finally { + this.isPublishing = false + this.connectButton.textContent = "Connect" + this.cameraSelect.disabled = false + this.microphoneSelect.disabled = false + } + } + } +} + +customElements.define("publisher-moq", PublisherMoq) +export default PublisherMoq diff --git a/lib/moq-publisher/publisher-moq.css b/lib/moq-publisher/publisher-moq.css new file mode 100644 index 0000000..35f812f --- /dev/null +++ b/lib/moq-publisher/publisher-moq.css @@ -0,0 +1,17 @@ +.publisher-container { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +#cameraSelect, +#microphoneSelect, +#connect { + font-size: 1rem; + padding: 0.5rem; +} + +#preview { + background: black; + object-fit: cover; +} diff --git a/lib/package.json b/lib/package.json index 7c653f1..9160c58 100644 --- a/lib/package.json +++ b/lib/package.json @@ -4,19 +4,27 @@ "description": "Media over QUIC library", "license": "(MIT OR Apache-2.0)", "wc-player": "video-moq/index.ts", + "wc-publisher": "moq-publisher/index.ts", "simple-player": "playback/index.ts", - "files": ["dist"], + "files": [ + "dist" + ], "exports": { ".": { "import": "./dist/moq-player.esm.js", "require": "./dist/moq-player.cjs.js" }, + "./moq-publisher": { + "import": "./dist/moq-publisher.esm.js", + "require": "./dist/moq-publisher.cjs.js" + }, "./simple-player": { "import": "./dist/moq-simple-player.esm.js", "require": "./dist/moq-simple-player.cjs.js" } }, "iife": "dist/moq-player.iife.js", + "iife-publisher": "dist/moq-publisher.iife.js", "iife-simple": "dist/moq-simple-player.iife.js", "types": "dist/types/moq-player.d.ts", "scripts": { @@ -58,8 +66,18 @@ "mp4box": "^0.5.2" }, "browserslist": { - "production": ["chrome >= 97", "edge >= 98", "firefox >= 130", "opera >= 83", "safari >= 18"], - "development": ["last 1 chrome version", "last 1 firefox version", "last 1 safari version"] + "production": [ + "chrome >= 97", + "edge >= 98", + "firefox >= 130", + "opera >= 83", + "safari >= 18" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] }, "repository": { "type": "git", diff --git a/lib/publish/index.ts b/lib/publish/index.ts new file mode 100644 index 0000000..b82c564 --- /dev/null +++ b/lib/publish/index.ts @@ -0,0 +1,56 @@ +// publisher-api.ts +import { Client } from "../transport/client" +import { Broadcast, BroadcastConfig } from "../contribute" +import { Connection } from "../transport/connection" + +export interface PublisherOptions { + url: string + namespace: string[] + media: MediaStream + video?: VideoEncoderConfig + audio?: AudioEncoderConfig + fingerprintUrl?: string +} + +export class PublisherApi { + private client: Client + private connection?: Connection + private broadcast?: Broadcast + private opts: PublisherOptions + + constructor(opts: PublisherOptions) { + this.opts = opts + this.client = new Client({ + url: opts.url, + fingerprint: opts.fingerprintUrl, + role: "publisher", + }) + } + + async publish(): Promise { + if (!this.connection) { + this.connection = await this.client.connect() + } + + const bcConfig: BroadcastConfig = { + connection: this.connection, + namespace: this.opts.namespace, + media: this.opts.media, + video: this.opts.video, + audio: this.opts.audio, + } + + this.broadcast = new Broadcast(bcConfig) + } + + async stop(): Promise { + if (this.broadcast) { + this.broadcast.close() + await this.broadcast.closed() + } + if (this.connection) { + this.connection.close() + await this.connection.closed() + } + } +} diff --git a/lib/rollup.config.js b/lib/rollup.config.js index 22f5292..fdee046 100644 --- a/lib/rollup.config.js +++ b/lib/rollup.config.js @@ -31,6 +31,23 @@ const basePlugins = [ ] module.exports = [ + { + input: pkg["wc-publisher"], + output: [ + { + file: pkg["iife-publisher"], + format: "iife", + name: "MoqPublisher", + sourcemap: true, + }, + { + file: pkg.exports["./moq-publisher"].import, + format: "esm", + sourcemap: true, + }, + ], + plugins: [...basePlugins, css()], + }, { input: pkg["wc-player"], output: [ diff --git a/package-lock.json b/package-lock.json index 11b4ae8..dfd4820 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ }, "lib": { "name": "@moq-js/player", - "version": "0.2.0", + "version": "0.4.3", "license": "(MIT OR Apache-2.0)", "dependencies": { "mp4box": "^0.5.2" diff --git a/samples/publisher/index.html b/samples/publisher/index.html new file mode 100644 index 0000000..50d5da1 --- /dev/null +++ b/samples/publisher/index.html @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file