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,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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user