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:
2026-07-10 19:06:43 +00:00
co-authored by Claude Fable 5
parent 594a9a8783
commit 5216c6451e
26 changed files with 4144 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>podpuddle</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2812
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
{
"name": "podpuddle-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -p tsconfig.json && vite build",
"preview": "vite preview"
},
"dependencies": {
"hls.js": "^1.6.0",
"nostr-tools": "^2.15.0",
"pinia": "^3.0.0",
"vue": "^3.5.0",
"vue-router": "^4.6.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.5.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.9.0",
"vite": "^7.2.0",
"vue-tsc": "^3.1.0"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+35
View File
@@ -0,0 +1,35 @@
<script setup lang="ts">
import { useAuthStore } from './stores/auth';
import { useRouter } from 'vue-router';
const auth = useAuthStore();
const router = useRouter();
async function logout() {
await auth.logout();
router.push('/login');
}
</script>
<template>
<div class="min-h-screen bg-slate-50 text-slate-900">
<header class="border-b border-slate-200 bg-white">
<div class="mx-auto flex max-w-4xl items-center justify-between px-4 py-3">
<RouterLink to="/" class="flex items-center gap-2 text-lg font-bold text-puddle-700">
<span aria-hidden="true">💧</span> podpuddle
</RouterLink>
<nav v-if="auth.pubkey" class="flex items-center gap-4 text-sm">
<RouterLink to="/" class="hover:text-puddle-600">Home</RouterLink>
<RouterLink to="/settings" class="hover:text-puddle-600">Settings</RouterLink>
<button class="btn-secondary !px-3 !py-1" @click="logout">
<span class="max-w-[8rem] truncate font-mono text-xs">{{ auth.displayName || auth.pubkey.slice(0, 8) + '…' }}</span>
Logout
</button>
</nav>
</div>
</header>
<main class="mx-auto max-w-4xl px-4 py-8">
<RouterView />
</main>
</div>
</template>
+32
View File
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { ref } from 'vue';
const props = defineProps<{ label: string; value: string; secret?: boolean }>();
const copied = ref(false);
const revealed = ref(false);
async function copy() {
await navigator.clipboard.writeText(props.value);
copied.value = true;
setTimeout(() => (copied.value = false), 1500);
}
</script>
<template>
<div>
<span class="label">{{ label }}</span>
<div class="flex gap-2">
<input
class="input flex-1 font-mono text-sm"
:type="secret && !revealed ? 'password' : 'text'"
:value="value"
readonly
@focus="($event.target as HTMLInputElement).select()"
/>
<button v-if="secret" class="btn-secondary" type="button" @click="revealed = !revealed">
{{ revealed ? 'Hide' : 'Show' }}
</button>
<button class="btn-secondary" type="button" @click="copy">{{ copied ? 'Copied!' : 'Copy' }}</button>
</div>
</div>
</template>
+40
View File
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import Hls from 'hls.js';
const props = defineProps<{ src: string; muted?: boolean }>();
const video = ref<HTMLVideoElement>();
let hls: Hls | null = null;
function attach() {
if (!video.value) return;
detach();
if (Hls.isSupported()) {
hls = new Hls({ liveSyncDurationCount: 2 });
hls.loadSource(props.src);
hls.attachMedia(video.value);
} else if (video.value.canPlayType('application/vnd.apple.mpegurl')) {
video.value.src = props.src; // Safari native HLS
}
}
function detach() {
hls?.destroy();
hls = null;
}
onMounted(attach);
watch(() => props.src, attach);
onBeforeUnmount(detach);
</script>
<template>
<video
ref="video"
class="aspect-video w-full rounded-lg bg-black"
controls
autoplay
playsinline
:muted="muted"
/>
</template>
+109
View File
@@ -0,0 +1,109 @@
<script setup lang="ts">
// Wizard questions for creating (or editing) a podcast, including the
// lightning address used for the RSS value-for-value block.
import { reactive, ref } from 'vue';
import { api } from '../lib/api';
export interface PodcastPayload {
title: string;
description: string;
author: string;
image_url?: string | null;
language: string;
category: string;
explicit: boolean;
lightning_address?: string | null;
}
const emit = defineEmits<{ created: [podcast: { id: string; title: string; feed_url: string }] }>();
const form = reactive<PodcastPayload>({
title: '',
description: '',
author: '',
image_url: '',
language: 'en',
category: 'Technology',
explicit: false,
lightning_address: '',
});
const error = ref('');
const busy = ref(false);
const categories = [
'Arts', 'Business', 'Comedy', 'Education', 'Fiction', 'Government', 'History',
'Health & Fitness', 'Kids & Family', 'Leisure', 'Music', 'News', 'Religion & Spirituality',
'Science', 'Society & Culture', 'Sports', 'Technology', 'True Crime', 'TV & Film',
];
async function submit() {
error.value = '';
busy.value = true;
try {
const created = await api.post<{ id: string; title: string; feed_url: string }>('/api/podcasts', {
...form,
image_url: form.image_url || null,
lightning_address: form.lightning_address || null,
});
emit('created', created);
} catch (err) {
error.value = (err as Error).message;
} finally {
busy.value = false;
}
}
</script>
<template>
<form class="space-y-4" @submit.prevent="submit">
<div>
<label class="label" for="pc-title">What is your podcast called?</label>
<input id="pc-title" v-model="form.title" class="input" required maxlength="200" placeholder="My Great Show" />
</div>
<div>
<label class="label" for="pc-desc">Describe it in a sentence or two</label>
<textarea id="pc-desc" v-model="form.description" class="input" rows="3" placeholder="A show about…" />
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="label" for="pc-author">Who is the author?</label>
<input id="pc-author" v-model="form.author" class="input" placeholder="Your name" />
</div>
<div>
<label class="label" for="pc-cat">Category</label>
<select id="pc-cat" v-model="form.category" class="input">
<option v-for="c in categories" :key="c">{{ c }}</option>
</select>
</div>
</div>
<div>
<label class="label" for="pc-ln">
Lightning address for listener payments
<span class="font-normal text-slate-400">(value-for-value; optional)</span>
</label>
<input
id="pc-ln"
v-model="form.lightning_address"
class="input"
placeholder="you@getalby.com"
pattern="[\w.+-]+@[\w.-]+"
/>
<p class="mt-1 text-xs text-slate-400">
Podcast apps like Fountain and Podverse can stream sats to this address while people listen.
</p>
</div>
<div>
<label class="label" for="pc-img">Cover image URL <span class="font-normal text-slate-400">(optional)</span></label>
<input id="pc-img" v-model="form.image_url" class="input" type="url" placeholder="https://…/cover.jpg" />
</div>
<label class="flex items-center gap-2 text-sm">
<input v-model="form.explicit" type="checkbox" class="rounded" />
Contains explicit content
</label>
<p v-if="error" class="rounded-lg bg-red-50 p-3 text-sm text-red-700">{{ error }}</p>
<button class="btn-primary" type="submit" :disabled="busy || !form.title">
{{ busy ? 'Creating…' : 'Create podcast' }}
</button>
</form>
</template>
+37
View File
@@ -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),
};
+76
View File
@@ -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);
});
}
+48
View 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))}`;
}
+62
View File
@@ -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();
}
});
});
}
+7
View File
@@ -0,0 +1,7 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
import { router } from './router';
import './style.css';
createApp(App).use(createPinia()).use(router).mount('#app');
+21
View File
@@ -0,0 +1,21 @@
import { createRouter, createWebHistory } from 'vue-router';
import { useAuthStore } from './stores/auth';
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', component: () => import('./views/LoginView.vue') },
{ path: '/', component: () => import('./views/HomeView.vue') },
{ path: '/upload', component: () => import('./views/wizard/EpisodeWizard.vue') },
{ path: '/live', component: () => import('./views/wizard/LiveWizard.vue') },
{ path: '/streams/:id', component: () => import('./views/StreamDashboard.vue') },
{ path: '/settings', component: () => import('./views/SettingsView.vue') },
],
});
router.beforeEach(async (to) => {
const auth = useAuthStore();
await auth.ensureLoaded();
if (to.path !== '/login' && !auth.pubkey) return '/login';
if (to.path === '/login' && auth.pubkey) return '/';
});
+52
View File
@@ -0,0 +1,52 @@
import { defineStore } from 'pinia';
import { api, ApiError } from '../lib/api';
import { buildNip98Header, hasNip07 } from '../lib/nip07';
interface Me {
pubkey: string;
displayName: string | null;
lud16: string | null;
isAdmin: boolean;
}
export const useAuthStore = defineStore('auth', {
state: () => ({
pubkey: null as string | null,
displayName: null as string | null,
isAdmin: false,
loaded: false,
}),
actions: {
async ensureLoaded() {
if (this.loaded) return;
try {
const me = await api.get<Me>('/api/auth/me');
this.pubkey = me.pubkey;
this.displayName = me.displayName;
this.isAdmin = me.isAdmin;
} catch (err) {
if (!(err instanceof ApiError && err.status === 401)) throw err;
} finally {
this.loaded = true;
}
},
async login() {
if (!hasNip07()) throw new Error('No nostr extension found — install Alby or nos2x first.');
const url = `${location.origin}/api/auth/login`;
const header = await buildNip98Header(url, 'POST');
const res = await api.post<{ pubkey: string; isAdmin: boolean }>(
'/api/auth/login',
undefined,
{ authorization: header },
);
this.pubkey = res.pubkey;
this.isAdmin = res.isAdmin;
},
async logout() {
await api.post('/api/auth/logout');
this.pubkey = null;
this.displayName = null;
this.isAdmin = false;
},
},
});
+26
View File
@@ -0,0 +1,26 @@
import { defineStore } from 'pinia';
import { api } from '../lib/api';
export interface PublicSettings {
blossomUrl: string;
relays: string[];
publicUrl: string;
serverPubkey: string;
rtmpPublic: string;
whipPublic: string;
hlsPublic: string;
}
export const useSettingsStore = defineStore('settings', {
state: () => ({
public: null as PublicSettings | null,
}),
actions: {
async ensureLoaded() {
if (!this.public) {
this.public = await api.get<PublicSettings>('/api/settings/public');
}
return this.public;
},
},
});
+27
View File
@@ -0,0 +1,27 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components {
.btn {
@apply inline-flex items-center justify-center gap-2 rounded-lg px-4 py-2 font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50;
}
.btn-primary {
@apply btn bg-puddle-600 text-white hover:bg-puddle-700;
}
.btn-secondary {
@apply btn border border-slate-300 bg-white text-slate-700 hover:bg-slate-50;
}
.btn-danger {
@apply btn bg-red-600 text-white hover:bg-red-700;
}
.input {
@apply w-full rounded-lg border border-slate-300 px-3 py-2 text-slate-900 placeholder-slate-400 focus:border-puddle-500 focus:outline-none focus:ring-1 focus:ring-puddle-500;
}
.label {
@apply mb-1 block text-sm font-medium text-slate-700;
}
.card {
@apply rounded-xl border border-slate-200 bg-white p-6 shadow-sm;
}
}
+74
View File
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { api } from '../lib/api';
interface StreamSummary {
id: string;
title: string;
status: 'planned' | 'live' | 'ended';
}
interface PodcastSummary {
id: string;
title: string;
feed_url: string;
}
const streams = ref<StreamSummary[]>([]);
const podcasts = ref<PodcastSummary[]>([]);
onMounted(async () => {
[streams.value, podcasts.value] = await Promise.all([
api.get<StreamSummary[]>('/api/streams'),
api.get<PodcastSummary[]>('/api/podcasts'),
]);
});
</script>
<template>
<div class="space-y-8">
<div class="grid gap-4 sm:grid-cols-2">
<RouterLink to="/live" class="card block transition-shadow hover:shadow-md">
<div class="mb-2 text-3xl" aria-hidden="true">🔴</div>
<h2 class="text-lg font-semibold">Go live</h2>
<p class="text-sm text-slate-500">
Stream from OBS or your browser. Announced on nostr, watchable anywhere via HLS.
</p>
</RouterLink>
<RouterLink to="/upload" class="card block transition-shadow hover:shadow-md">
<div class="mb-2 text-3xl" aria-hidden="true">🎙</div>
<h2 class="text-lg font-semibold">Upload an episode</h2>
<p class="text-sm text-slate-500">
Publish an mp4 to your own RSS feed with lightning payment info, stored on Blossom.
</p>
</RouterLink>
</div>
<section v-if="streams.length">
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">Your streams</h3>
<ul class="space-y-2">
<li v-for="s in streams" :key="s.id">
<RouterLink :to="`/streams/${s.id}`" class="card flex items-center justify-between !p-4 hover:shadow-md">
<span class="font-medium">{{ s.title }}</span>
<span
class="rounded-full px-2 py-0.5 text-xs font-semibold"
:class="{
'bg-red-100 text-red-700': s.status === 'live',
'bg-slate-100 text-slate-600': s.status !== 'live',
}"
>{{ s.status === 'live' ? '● LIVE' : s.status }}</span>
</RouterLink>
</li>
</ul>
</section>
<section v-if="podcasts.length">
<h3 class="mb-3 text-sm font-semibold uppercase tracking-wide text-slate-500">Your podcasts</h3>
<ul class="space-y-2">
<li v-for="p in podcasts" :key="p.id" class="card flex items-center justify-between !p-4">
<span class="font-medium">{{ p.title }}</span>
<a :href="p.feed_url" target="_blank" rel="noopener" class="text-sm text-puddle-600 underline">RSS feed</a>
</li>
</ul>
</section>
</div>
</template>
+54
View File
@@ -0,0 +1,54 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '../stores/auth';
import { hasNip07 } from '../lib/nip07';
const auth = useAuthStore();
const router = useRouter();
const error = ref('');
const busy = ref(false);
async function login() {
error.value = '';
busy.value = true;
try {
await auth.login();
router.push('/');
} catch (err) {
error.value = (err as Error).message;
} finally {
busy.value = false;
}
}
</script>
<template>
<div class="mx-auto max-w-md">
<div class="card text-center">
<div class="mb-2 text-4xl" aria-hidden="true">💧</div>
<h1 class="mb-1 text-2xl font-bold">podpuddle</h1>
<p class="mb-6 text-slate-500">Publish podcasts and go live on nostr, from your own server.</p>
<template v-if="hasNip07()">
<button class="btn-primary w-full" :disabled="busy" @click="login">
{{ busy ? 'Waiting for your extension…' : 'Log in with nostr' }}
</button>
<p class="mt-3 text-xs text-slate-400">
Your extension signs a login request (NIP-98). No password, no email.
</p>
</template>
<template v-else>
<p class="mb-3 rounded-lg bg-amber-50 p-3 text-sm text-amber-800">
No nostr extension detected. Install a NIP-07 signer, then reload this page.
</p>
<div class="flex justify-center gap-3 text-sm">
<a class="text-puddle-600 underline" href="https://getalby.com" target="_blank" rel="noopener">Alby</a>
<a class="text-puddle-600 underline" href="https://github.com/fiatjaf/nos2x" target="_blank" rel="noopener">nos2x</a>
</div>
</template>
<p v-if="error" class="mt-4 rounded-lg bg-red-50 p-3 text-sm text-red-700">{{ error }}</p>
</div>
</div>
</template>
+73
View File
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { api } from '../lib/api';
import { useAuthStore } from '../stores/auth';
interface Settings {
blossom_url: string;
relays: string[];
public_url: string;
admin_pubkey: string | null;
}
const auth = useAuthStore();
const form = reactive({ blossom_url: '', relays: '', public_url: '' });
const saved = ref(false);
const error = ref('');
onMounted(async () => {
const s = await api.get<Settings>('/api/settings');
form.blossom_url = s.blossom_url;
form.relays = s.relays.join('\n');
form.public_url = s.public_url;
});
async function save() {
error.value = '';
saved.value = false;
try {
await api.put('/api/settings', {
blossom_url: form.blossom_url,
relays: form.relays.split('\n').map((r) => r.trim()).filter(Boolean),
public_url: form.public_url,
});
saved.value = true;
} catch (err) {
error.value = (err as Error).message;
}
}
</script>
<template>
<div class="mx-auto max-w-2xl space-y-6">
<h1 class="text-2xl font-bold">Settings</h1>
<form class="card space-y-4" @submit.prevent="save">
<div>
<label class="label" for="set-blossom">Blossom server URL</label>
<input id="set-blossom" v-model="form.blossom_url" class="input" type="url" required />
<p class="mt-1 text-xs text-slate-400">
Where uploaded media is stored. The bundled server is the default; point this at any
external Blossom server to store media there instead. Note: publishing stream
recordings uploads with the <em>server's</em> nostr key an external server must
accept uploads from that pubkey.
</p>
</div>
<div>
<label class="label" for="set-relays">Nostr relays (one per line)</label>
<textarea id="set-relays" v-model="form.relays" class="input font-mono text-sm" rows="4" />
<p class="mt-1 text-xs text-slate-400">Live streams (NIP-53) are announced to these relays.</p>
</div>
<div>
<label class="label" for="set-public">Public URL of this server</label>
<input id="set-public" v-model="form.public_url" class="input" type="url" required />
<p class="mt-1 text-xs text-slate-400">Used in RSS feed links. Must be reachable by podcast apps.</p>
</div>
<p v-if="!auth.isAdmin" class="rounded-lg bg-amber-50 p-3 text-sm text-amber-800">
Only the admin (first account to log in) can change settings.
</p>
<p v-if="error" class="rounded-lg bg-red-50 p-3 text-sm text-red-700">{{ error }}</p>
<p v-if="saved" class="rounded-lg bg-green-50 p-3 text-sm text-green-700">Saved.</p>
<button class="btn-primary" type="submit" :disabled="!auth.isAdmin">Save</button>
</form>
</div>
</template>
+215
View File
@@ -0,0 +1,215 @@
<script setup lang="ts">
// Stream control room: OBS credentials, browser (WHIP) publishing, live HLS
// preview, status, and post-stream recording publishing.
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useRoute } from 'vue-router';
import { api } from '../lib/api';
import { publishWhip, type WhipSession } from '../lib/whip';
import CopyField from '../components/CopyField.vue';
import HlsPlayer from '../components/HlsPlayer.vue';
interface StreamDetail {
id: string;
title: string;
summary: string;
status: 'planned' | 'live' | 'ended';
rtmpUrl: string;
whipUrl: string;
hlsUrl: string;
}
interface RecordingFile { file: string; path: string }
interface PodcastSummary { id: string; title: string; feed_url: string }
const route = useRoute();
const id = route.params.id as string;
// Present only right after creation (passed via history state) keys are shown once.
const streamKey = ref<string | null>((history.state?.streamKey as string) ?? null);
const whipBearer = ref<string | null>((history.state?.whipBearer as string) ?? null);
const stream = ref<StreamDetail | null>(null);
const error = ref('');
const isLive = computed(() => stream.value?.status === 'live');
let pollTimer: ReturnType<typeof setInterval> | null = null;
async function refresh() {
stream.value = await api.get<StreamDetail>(`/api/streams/${id}`);
}
onMounted(async () => {
await refresh();
pollTimer = setInterval(refresh, 4000);
await loadRecordings();
podcasts.value = await api.get<PodcastSummary[]>('/api/podcasts');
});
onBeforeUnmount(() => {
if (pollTimer) clearInterval(pollTimer);
whip.value?.stop();
});
async function rotateKey() {
const res = await api.post<{ streamKey: string; whipBearer: string }>(`/api/streams/${id}/rotate-key`);
streamKey.value = res.streamKey;
whipBearer.value = res.whipBearer;
}
async function endStream() {
await whip.value?.stop();
whip.value = null;
await api.post(`/api/streams/${id}/end`);
await refresh();
await loadRecordings();
}
// ---- browser (WHIP) publishing ----
const whip = ref<WhipSession | null>(null);
const whipBusy = ref(false);
async function goLiveFromBrowser(source: 'camera' | 'screen') {
if (!whipBearer.value) {
error.value = 'No stream key in this session — rotate the key to get a new one.';
return;
}
error.value = '';
whipBusy.value = true;
try {
const media =
source === 'camera'
? await navigator.mediaDevices.getUserMedia({ video: { width: 1280, height: 720 }, audio: true })
: await navigator.mediaDevices.getDisplayMedia({ video: true, audio: true });
whip.value = await publishWhip(stream.value!.whipUrl, whipBearer.value, media);
} catch (err) {
error.value = (err as Error).message;
} finally {
whipBusy.value = false;
}
}
async function stopBrowserPublish() {
await whip.value?.stop();
whip.value = null;
}
// ---- recordings ----
const recordings = ref<RecordingFile[]>([]);
const podcasts = ref<PodcastSummary[]>([]);
const publishTarget = ref('');
const publishingFile = ref('');
const publishedFeed = ref('');
async function loadRecordings() {
recordings.value = await api.get<RecordingFile[]>(`/api/streams/${id}/recordings`);
}
async function publishRecording(file: string) {
if (!publishTarget.value) return;
error.value = '';
publishingFile.value = file;
try {
await api.post(`/api/streams/${id}/recordings/publish`, {
file,
podcast_id: publishTarget.value,
title: `${stream.value?.title ?? 'Live stream'} — recording`,
description: stream.value?.summary ?? '',
});
publishedFeed.value = podcasts.value.find((p) => p.id === publishTarget.value)?.feed_url ?? '';
} catch (err) {
error.value = (err as Error).message;
} finally {
publishingFile.value = '';
}
}
</script>
<template>
<div v-if="stream" class="mx-auto max-w-2xl space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold">{{ stream.title }}</h1>
<span
class="rounded-full px-3 py-1 text-sm font-semibold"
:class="isLive ? 'bg-red-100 text-red-700' : 'bg-slate-100 text-slate-600'"
>{{ isLive ? '● LIVE' : stream.status }}</span>
</div>
<p v-if="error" class="rounded-lg bg-red-50 p-3 text-sm text-red-700">{{ error }}</p>
<!-- Live preview -->
<div v-if="isLive" class="card space-y-3">
<h2 class="font-semibold">Live preview</h2>
<HlsPlayer :src="stream.hlsUrl" muted />
<CopyField label="Share this viewer link (HLS — plays in zap.stream, VLC, browsers)" :value="stream.hlsUrl" />
<button class="btn-danger" @click="endStream">End stream</button>
</div>
<!-- OBS -->
<div class="card space-y-4">
<h2 class="font-semibold">Stream with OBS</h2>
<CopyField label="Server" :value="`${stream.rtmpUrl}`" />
<CopyField
v-if="streamKey"
label="Stream key (shown once — copy it now)"
:value="streamKey"
secret
/>
<div v-else class="rounded-lg bg-amber-50 p-3 text-sm text-amber-800">
The stream key is only shown right after creation.
<button class="underline" @click="rotateKey">Generate a new key</button> if you lost it.
</div>
<p class="text-xs text-slate-400">
In OBS: Settings Stream Service Custom, paste Server and Stream Key, then Start Streaming.
</p>
</div>
<!-- Browser -->
<div class="card space-y-4">
<h2 class="font-semibold">Or stream from this browser</h2>
<div v-if="!whip" class="flex gap-2">
<button class="btn-primary" :disabled="whipBusy || !whipBearer" @click="goLiveFromBrowser('camera')">
🎥 Camera + mic
</button>
<button class="btn-secondary" :disabled="whipBusy || !whipBearer" @click="goLiveFromBrowser('screen')">
🖥 Share screen
</button>
</div>
<div v-else class="flex items-center gap-3">
<span class="text-sm font-medium text-red-700">Publishing from this browser</span>
<button class="btn-danger" @click="stopBrowserPublish">Stop</button>
</div>
<p v-if="!whipBearer" class="text-xs text-slate-400">
Browser streaming needs the key from this session rotate the key above to enable it.
</p>
</div>
<!-- Recordings -->
<div v-if="recordings.length" class="card space-y-4">
<h2 class="font-semibold">Recordings</h2>
<div v-if="podcasts.length">
<label class="label" for="rec-target">Publish to podcast</label>
<select id="rec-target" v-model="publishTarget" class="input">
<option value="" disabled>Choose a podcast</option>
<option v-for="p in podcasts" :key="p.id" :value="p.id">{{ p.title }}</option>
</select>
</div>
<p v-else class="text-sm text-slate-500">
Create a podcast (via <RouterLink class="text-puddle-600 underline" to="/upload">Upload an episode</RouterLink>)
to publish recordings as episodes.
</p>
<ul class="space-y-2">
<li v-for="r in recordings" :key="r.file" class="flex items-center justify-between gap-3">
<span class="truncate font-mono text-sm">{{ r.file }}</span>
<button
class="btn-secondary shrink-0"
:disabled="!publishTarget || publishingFile === r.file"
@click="publishRecording(r.file)"
>
{{ publishingFile === r.file ? 'Publishing…' : 'Publish as episode' }}
</button>
</li>
</ul>
<p v-if="publishedFeed" class="rounded-lg bg-green-50 p-3 text-sm text-green-700">
Published! Feed: <a class="underline" :href="publishedFeed" target="_blank" rel="noopener">{{ publishedFeed }}</a>
</p>
</div>
</div>
</template>
+181
View File
@@ -0,0 +1,181 @@
<script setup lang="ts">
// Upload wizard: pick/create podcast choose mp4 hash + blossom upload
// episode details published (feed URL result).
import { computed, onMounted, ref } from 'vue';
import { api } from '../../lib/api';
import { probeDuration, sha256File, uploadToBlossom } from '../../lib/blossom';
import { useSettingsStore } from '../../stores/settings';
import PodcastForm from '../../components/PodcastForm.vue';
import CopyField from '../../components/CopyField.vue';
interface PodcastSummary { id: string; title: string; feed_url: string }
const settings = useSettingsStore();
const step = ref<'podcast' | 'file' | 'details' | 'done'>('podcast');
const podcasts = ref<PodcastSummary[]>([]);
const podcast = ref<PodcastSummary | null>(null);
const creatingNew = ref(false);
const file = ref<File | null>(null);
const phase = ref<'idle' | 'hashing' | 'uploading' | 'registering'>('idle');
const progress = ref(0);
const error = ref('');
const epTitle = ref('');
const epDescription = ref('');
const uploaded = ref<{ sha256: string; size: number } | null>(null);
const durationSecs = ref<number | null>(null);
const episodeUrl = ref('');
const phaseLabel = computed(() => ({
idle: '',
hashing: 'Computing sha256…',
uploading: `Uploading to Blossom… ${(progress.value * 100).toFixed(0)}%`,
registering: 'Publishing episode…',
}[phase.value]));
onMounted(async () => {
await settings.ensureLoaded();
podcasts.value = await api.get<PodcastSummary[]>('/api/podcasts');
if (podcasts.value.length === 0) creatingNew.value = true;
});
function choosePodcast(p: PodcastSummary) {
podcast.value = p;
step.value = 'file';
}
function onPodcastCreated(p: PodcastSummary) {
podcasts.value.push(p);
choosePodcast(p);
}
function onFilePicked(e: Event) {
const f = (e.target as HTMLInputElement).files?.[0];
if (!f) return;
if (f.size > 2 * 1024 * 1024 * 1024) {
error.value = 'File is larger than 2 GB — not supported yet.';
return;
}
error.value = '';
file.value = f;
epTitle.value ||= f.name.replace(/\.\w+$/, '');
step.value = 'details';
}
async function publish() {
if (!file.value || !podcast.value) return;
error.value = '';
try {
phase.value = 'hashing';
durationSecs.value = await probeDuration(file.value);
const sha = await sha256File(file.value);
phase.value = 'uploading';
const blossomUrl = settings.public!.blossomUrl;
await uploadToBlossom(blossomUrl, file.value, sha, (f) => (progress.value = f));
uploaded.value = { sha256: sha, size: file.value.size };
phase.value = 'registering';
const episode = await api.post<{ enclosure_url: string }>(
`/api/podcasts/${podcast.value.id}/episodes`,
{
title: epTitle.value,
description: epDescription.value,
sha256: sha,
size: file.value.size,
mime: file.value.type || 'video/mp4',
duration_secs: durationSecs.value,
},
);
episodeUrl.value = episode.enclosure_url;
step.value = 'done';
} catch (err) {
error.value = (err as Error).message;
} finally {
phase.value = 'idle';
}
}
</script>
<template>
<div class="mx-auto max-w-2xl space-y-6">
<h1 class="text-2xl font-bold">Upload an episode</h1>
<!-- Step 1: podcast -->
<div v-if="step === 'podcast'" class="card space-y-4">
<template v-if="podcasts.length && !creatingNew">
<h2 class="font-semibold">Which podcast is this episode for?</h2>
<ul class="space-y-2">
<li v-for="p in podcasts" :key="p.id">
<button class="btn-secondary w-full !justify-start" @click="choosePodcast(p)">{{ p.title }}</button>
</li>
</ul>
<button class="text-sm text-puddle-600 underline" @click="creatingNew = true">+ Start a new podcast</button>
</template>
<template v-else>
<h2 class="font-semibold">{{ podcasts.length ? 'New podcast' : 'First, set up your podcast' }}</h2>
<PodcastForm @created="onPodcastCreated" />
<button v-if="podcasts.length" class="text-sm text-slate-500 underline" @click="creatingNew = false">
Back to existing podcasts
</button>
</template>
</div>
<!-- Step 2: file -->
<div v-else-if="step === 'file'" class="card space-y-4">
<h2 class="font-semibold">Choose the mp4 to publish to {{ podcast?.title }}</h2>
<input type="file" accept="video/mp4,audio/mpeg,audio/mp4" class="input" @change="onFilePicked" />
<p class="text-xs text-slate-400">
The file is hashed in your browser and uploaded straight to the Blossom server
({{ settings.public?.blossomUrl }}), signed with your nostr key.
</p>
<p v-if="error" class="rounded-lg bg-red-50 p-3 text-sm text-red-700">{{ error }}</p>
</div>
<!-- Step 3: details + upload -->
<div v-else-if="step === 'details'" class="card space-y-4">
<h2 class="font-semibold">Episode details</h2>
<p class="text-sm text-slate-500">{{ file?.name }} · {{ ((file?.size ?? 0) / 1048576).toFixed(1) }} MB</p>
<div>
<label class="label" for="ep-title">Episode title</label>
<input id="ep-title" v-model="epTitle" class="input" required maxlength="300" />
</div>
<div>
<label class="label" for="ep-desc">Show notes</label>
<textarea id="ep-desc" v-model="epDescription" class="input" rows="4" placeholder="What happens in this episode?" />
</div>
<div v-if="phase !== 'idle'" class="space-y-2">
<div class="h-2 overflow-hidden rounded-full bg-slate-200">
<div class="h-full bg-puddle-500 transition-all" :style="{ width: `${progress * 100}%` }" />
</div>
<p class="text-sm text-slate-500">{{ phaseLabel }}</p>
</div>
<p v-if="error" class="rounded-lg bg-red-50 p-3 text-sm text-red-700">{{ error }}</p>
<div class="flex gap-2">
<button class="btn-secondary" :disabled="phase !== 'idle'" @click="step = 'file'">Back</button>
<button class="btn-primary" :disabled="phase !== 'idle' || !epTitle" @click="publish">
{{ phase === 'idle' ? 'Upload & publish' : 'Working…' }}
</button>
</div>
</div>
<!-- Step 4: done -->
<div v-else class="card space-y-4">
<h2 class="text-lg font-semibold text-green-700">🎉 Episode published!</h2>
<CopyField label="Your RSS feed — subscribe in any podcast app" :value="podcast!.feed_url" />
<CopyField label="Direct media URL (on Blossom)" :value="episodeUrl" />
<p class="text-sm text-slate-500">
Feed includes your lightning payment info (Podcasting 2.0 value block).
Validate it with
<a class="text-puddle-600 underline" :href="`https://validator.livewire.io/?feed_url=${encodeURIComponent(podcast!.feed_url)}`" target="_blank" rel="noopener">Livewire</a>
or submit it to
<a class="text-puddle-600 underline" href="https://podcastindex.org/add" target="_blank" rel="noopener">Podcast Index</a>.
</p>
<div class="flex gap-2">
<RouterLink to="/" class="btn-secondary">Home</RouterLink>
<button class="btn-primary" @click="step = 'file'; file = null">Upload another</button>
</div>
</div>
</div>
</template>
+71
View File
@@ -0,0 +1,71 @@
<script setup lang="ts">
// Go-live wizard: stream details created hand off to the dashboard,
// which offers OBS credentials and browser (WHIP) publishing.
import { reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { api } from '../../lib/api';
const router = useRouter();
const error = ref('');
const busy = ref(false);
const form = reactive({
title: '',
summary: '',
image_url: '',
hashtags: '',
});
async function create() {
error.value = '';
busy.value = true;
try {
const stream = await api.post<{ id: string; streamKey: string; whipBearer: string }>('/api/streams', {
title: form.title,
summary: form.summary,
image_url: form.image_url || null,
hashtags: form.hashtags.split(',').map((t) => t.trim().replace(/^#/, '')).filter(Boolean),
});
// The key is only returned once pass it to the dashboard via history state.
router.push({ path: `/streams/${stream.id}`, state: { streamKey: stream.streamKey, whipBearer: stream.whipBearer } });
} catch (err) {
error.value = (err as Error).message;
} finally {
busy.value = false;
}
}
</script>
<template>
<div class="mx-auto max-w-2xl space-y-6">
<h1 class="text-2xl font-bold">Go live</h1>
<form class="card space-y-4" @submit.prevent="create">
<div>
<label class="label" for="st-title">What are you streaming?</label>
<input id="st-title" v-model="form.title" class="input" required maxlength="300" placeholder="Stream title" />
</div>
<div>
<label class="label" for="st-summary">Short description</label>
<textarea id="st-summary" v-model="form.summary" class="input" rows="3" placeholder="Tell viewers what to expect" />
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="label" for="st-image">Preview image URL <span class="font-normal text-slate-400">(optional)</span></label>
<input id="st-image" v-model="form.image_url" class="input" type="url" placeholder="https://…/preview.jpg" />
</div>
<div>
<label class="label" for="st-tags">Hashtags <span class="font-normal text-slate-400">(comma separated)</span></label>
<input id="st-tags" v-model="form.hashtags" class="input" placeholder="music, coding" />
</div>
</div>
<p class="text-xs text-slate-400">
Creating the stream announces it on nostr (NIP-53). Viewers on zap.stream and other
nostr clients will see it go live automatically.
</p>
<p v-if="error" class="rounded-lg bg-red-50 p-3 text-sm text-red-700">{{ error }}</p>
<button class="btn-primary" type="submit" :disabled="busy || !form.title">
{{ busy ? 'Creating…' : 'Create stream' }}
</button>
</form>
</div>
</template>
+19
View File
@@ -0,0 +1,19 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{vue,ts}'],
theme: {
extend: {
colors: {
puddle: {
50: '#eef7ff',
100: '#d9edff',
500: '#2e90fa',
600: '#1570cd',
700: '#0f5aa8',
900: '#0b3866',
},
},
},
},
plugins: [],
};
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"useDefineForClassFields": true,
"verbatimModuleSyntax": true,
"jsx": "preserve"
},
"include": ["src/**/*.ts", "src/**/*.vue"]
}
+12
View File
@@ -0,0 +1,12 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
proxy: {
'/api': 'http://localhost:8095',
'/feeds': 'http://localhost:8095',
},
},
});