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,37 @@
|
||||
// Thin fetch wrapper for the podpuddle API (session cookie auth).
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown, headers?: Record<string, string>): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method,
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
||||
...headers,
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
let message = res.statusText;
|
||||
try {
|
||||
message = ((await res.json()) as { error?: string }).error ?? message;
|
||||
} catch {
|
||||
/* keep statusText */
|
||||
}
|
||||
throw new ApiError(res.status, message);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>('GET', path),
|
||||
post: <T>(path: string, body?: unknown, headers?: Record<string, string>) =>
|
||||
request<T>('POST', path, body, headers),
|
||||
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
|
||||
delete: <T>(path: string) => request<T>('DELETE', path),
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
// Browser-direct Blossom (BUD-02) upload: hash locally, sign the kind-24242
|
||||
// authorization with the user's NIP-07 extension, PUT straight to the blossom
|
||||
// server. The file never transits podpuddle.
|
||||
|
||||
import { nip07 } from './nip07';
|
||||
|
||||
export async function sha256File(file: File, onProgress?: (frac: number) => void): Promise<string> {
|
||||
// crypto.subtle needs the whole buffer; fine for podcast-sized mp4s (~2 GB cap).
|
||||
const buf = await file.arrayBuffer();
|
||||
onProgress?.(0.5);
|
||||
const digest = await crypto.subtle.digest('SHA-256', buf);
|
||||
onProgress?.(1);
|
||||
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
export interface BlossomUpload {
|
||||
sha256: string;
|
||||
size: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export async function uploadToBlossom(
|
||||
blossomUrl: string,
|
||||
file: File,
|
||||
sha256: string,
|
||||
onProgress?: (frac: number) => void,
|
||||
): Promise<BlossomUpload> {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const auth = await nip07().signEvent({
|
||||
kind: 24242,
|
||||
created_at: now,
|
||||
content: `Upload ${file.name}`,
|
||||
tags: [
|
||||
['t', 'upload'],
|
||||
['x', sha256],
|
||||
['expiration', String(now + 600)],
|
||||
],
|
||||
});
|
||||
|
||||
const base = blossomUrl.replace(/\/+$/, '');
|
||||
// XHR instead of fetch for upload progress events.
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('PUT', `${base}/upload`);
|
||||
xhr.setRequestHeader('authorization', `Nostr ${btoa(JSON.stringify(auth))}`);
|
||||
xhr.setRequestHeader('content-type', file.type || 'video/mp4');
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) onProgress?.(e.loaded / e.total);
|
||||
};
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve();
|
||||
else reject(new Error(`blossom upload failed: ${xhr.status} ${xhr.responseText.slice(0, 200)}`));
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('blossom upload failed: network error'));
|
||||
xhr.send(file);
|
||||
});
|
||||
|
||||
return { sha256, size: file.size, url: `${base}/${sha256}` };
|
||||
}
|
||||
|
||||
/** Read the media duration (seconds) from a local file, for itunes:duration. */
|
||||
export function probeDuration(file: File): Promise<number | null> {
|
||||
return new Promise((resolve) => {
|
||||
const el = document.createElement('video');
|
||||
el.preload = 'metadata';
|
||||
el.onloadedmetadata = () => {
|
||||
URL.revokeObjectURL(el.src);
|
||||
resolve(Number.isFinite(el.duration) ? Math.round(el.duration) : null);
|
||||
};
|
||||
el.onerror = () => {
|
||||
URL.revokeObjectURL(el.src);
|
||||
resolve(null);
|
||||
};
|
||||
el.src = URL.createObjectURL(file);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// NIP-07 browser extension bridge + NIP-98 header construction.
|
||||
|
||||
export interface UnsignedEvent {
|
||||
kind: number;
|
||||
created_at: number;
|
||||
content: string;
|
||||
tags: string[][];
|
||||
}
|
||||
|
||||
export interface SignedEvent extends UnsignedEvent {
|
||||
id: string;
|
||||
pubkey: string;
|
||||
sig: string;
|
||||
}
|
||||
|
||||
interface Nip07Provider {
|
||||
getPublicKey(): Promise<string>;
|
||||
signEvent(event: UnsignedEvent): Promise<SignedEvent>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
nostr?: Nip07Provider;
|
||||
}
|
||||
}
|
||||
|
||||
export function hasNip07(): boolean {
|
||||
return typeof window !== 'undefined' && !!window.nostr;
|
||||
}
|
||||
|
||||
export function nip07(): Nip07Provider {
|
||||
if (!window.nostr) throw new Error('No NIP-07 nostr extension found');
|
||||
return window.nostr;
|
||||
}
|
||||
|
||||
/** Sign a NIP-98 (kind 27235) event for the given request and return the Authorization header value. */
|
||||
export async function buildNip98Header(url: string, method: string): Promise<string> {
|
||||
const event = await nip07().signEvent({
|
||||
kind: 27235,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
content: '',
|
||||
tags: [
|
||||
['u', url],
|
||||
['method', method],
|
||||
],
|
||||
});
|
||||
return `Nostr ${btoa(JSON.stringify(event))}`;
|
||||
}
|
||||
@@ -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