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,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>
|
||||
Generated
+2812
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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 '/';
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user