feat(frontend): Vue 3 wizard UI (nostr login, episode upload, go-live)
- NIP-07 login → NIP-98 signed request → session cookie - episode wizard: pick/create podcast (incl. lightning address question), browser-side sha256 + BUD-02 blossom upload with progress, feed result page - live wizard + stream dashboard: OBS server/key (shown once, rotatable), browser WHIP publishing (camera or screen), hls.js live preview, publish-recording-as-episode flow - settings view: external blossom URL, relay list, public URL (admin only) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
// Minimal WHIP (WebRTC-HTTP Ingestion Protocol) publisher for MediaMTX.
|
||||
|
||||
export interface WhipSession {
|
||||
pc: RTCPeerConnection;
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
export async function publishWhip(
|
||||
whipUrl: string,
|
||||
bearer: string,
|
||||
stream: MediaStream,
|
||||
): Promise<WhipSession> {
|
||||
const pc = new RTCPeerConnection();
|
||||
for (const track of stream.getTracks()) {
|
||||
pc.addTransceiver(track, { direction: 'sendonly' });
|
||||
}
|
||||
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
await waitIceComplete(pc);
|
||||
|
||||
const res = await fetch(whipUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/sdp',
|
||||
authorization: `Bearer ${bearer}`,
|
||||
},
|
||||
body: pc.localDescription!.sdp,
|
||||
});
|
||||
if (!res.ok) {
|
||||
pc.close();
|
||||
throw new Error(`WHIP publish failed: ${res.status} ${await res.text().catch(() => '')}`);
|
||||
}
|
||||
const location = res.headers.get('location');
|
||||
const answer = await res.text();
|
||||
await pc.setRemoteDescription({ type: 'answer', sdp: answer });
|
||||
|
||||
return {
|
||||
pc,
|
||||
stop: async () => {
|
||||
pc.close();
|
||||
for (const track of stream.getTracks()) track.stop();
|
||||
if (location) {
|
||||
const url = new URL(location, whipUrl).toString();
|
||||
await fetch(url, { method: 'DELETE', headers: { authorization: `Bearer ${bearer}` } }).catch(() => {});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function waitIceComplete(pc: RTCPeerConnection): Promise<void> {
|
||||
if (pc.iceGatheringState === 'complete') return Promise.resolve();
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(resolve, 2000); // trickle fallback: send what we have
|
||||
pc.addEventListener('icegatheringstatechange', () => {
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user