{recordings.map((r) => {
const dur = formatDuration(r.duration_seconds);
diff --git a/apps/web/src/app/c/[handle]/rss.xml/route.ts b/apps/web/src/app/c/[handle]/rss.xml/route.ts
new file mode 100644
index 00000000..805f7ed3
--- /dev/null
+++ b/apps/web/src/app/c/[handle]/rss.xml/route.ts
@@ -0,0 +1,126 @@
+import { createClient } from '@/lib/supabase/server';
+import { SITE_URL, clockDuration, escapeXml, liveUrl } from '@/lib/embed';
+
+/**
+ * Per-channel RSS feed of finished recordings.
+ *
+ * This is what makes a PairUX channel subscribable outside pairux.com — the
+ * itunes:* tags and the
on each item let podcast apps (Apple
+ * Podcasts, Overcast, Pocket Casts) and ordinary feed readers treat a channel
+ * as a show whose episodes are its past lives.
+ */
+
+export const dynamic = 'force-dynamic';
+
+interface ChannelRow {
+ id: string;
+ handle: string;
+ name: string;
+ description: string | null;
+ avatar_url: string | null;
+ banner_url: string | null;
+}
+
+interface RecordingRow {
+ id: string;
+ join_code: string;
+ subject: string | null;
+ banner_url: string | null;
+ playback_url: string;
+ duration_seconds: number | null;
+ created_at: string;
+}
+
+async function getChannel(handle: string): Promise {
+ try {
+ const supabase = await createClient();
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
+ const { data, error } = await (supabase.rpc as any)('get_channel', { p_handle: handle });
+ if (error) return null;
+ return (data as ChannelRow[] | null)?.[0] ?? null;
+ } catch {
+ return null;
+ }
+}
+
+async function getRecordings(handle: string): Promise {
+ try {
+ const supabase = await createClient();
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
+ const { data, error } = await (supabase.rpc as any)('list_channel_recordings', {
+ p_handle: handle,
+ p_limit: 100,
+ });
+ if (error) return [];
+ return (data as RecordingRow[] | null) ?? [];
+ } catch {
+ return [];
+ }
+}
+
+function buildItem(channel: ChannelRow, r: RecordingRow): string {
+ const title = r.subject ?? 'Untitled live';
+ const permalink = liveUrl(r.join_code);
+ const image = r.banner_url ?? channel.banner_url ?? channel.avatar_url;
+ const duration = clockDuration(r.duration_seconds);
+
+ return [
+ ' - ',
+ ` ${escapeXml(title)}`,
+ ` ${escapeXml(permalink)}`,
+ ` ${escapeXml(r.id)}`,
+ ` ${new Date(r.created_at).toUTCString()}`,
+ ` ${escapeXml(`${title} — a live from ${channel.name} on PairUX.`)}`,
+ // length is required by the RSS spec but the public RPC does not expose
+ // size_bytes; 0 is the conventional "unknown" and clients tolerate it.
+ ` `,
+ duration ? ` ${duration}` : null,
+ image ? ` ` : null,
+ ` ${escapeXml(channel.name)}`,
+ '
',
+ ]
+ .filter((line): line is string => line !== null)
+ .join('\n');
+}
+
+export async function GET(_request: Request, { params }: { params: Promise<{ handle: string }> }) {
+ const { handle } = await params;
+
+ const channel = await getChannel(handle);
+ if (!channel) {
+ return new Response('Channel not found', { status: 404 });
+ }
+
+ const recordings = await getRecordings(handle);
+ const channelUrl = `${SITE_URL}/@${channel.handle}`;
+ const feedUrl = `${SITE_URL}/c/${encodeURIComponent(channel.handle)}/rss.xml`;
+ const description = channel.description ?? `Past lives from ${channel.name}, recorded on PairUX.`;
+ const artwork = channel.avatar_url ?? channel.banner_url;
+
+ const xml = [
+ '',
+ '',
+ ' ',
+ ` ${escapeXml(channel.name)}`,
+ ` ${escapeXml(channelUrl)}`,
+ ` ${escapeXml(description)}`,
+ ' en',
+ ` `,
+ ` ${escapeXml(channel.name)}`,
+ ` ${escapeXml(description)}`,
+ ' false',
+ artwork ? ` ` : null,
+ ...recordings.map((r) => buildItem(channel, r)),
+ ' ',
+ '',
+ ]
+ .filter((line): line is string => line !== null)
+ .join('\n');
+
+ return new Response(xml, {
+ headers: {
+ 'content-type': 'application/rss+xml; charset=utf-8',
+ 'cache-control': 'public, max-age=300, s-maxage=300',
+ },
+ });
+}
diff --git a/apps/web/src/app/embed/[joinCode]/page.tsx b/apps/web/src/app/embed/[joinCode]/page.tsx
new file mode 100644
index 00000000..72ce1e0b
--- /dev/null
+++ b/apps/web/src/app/embed/[joinCode]/page.tsx
@@ -0,0 +1,136 @@
+import type { Metadata } from 'next';
+import { notFound } from 'next/navigation';
+import { Circle, Play } from 'lucide-react';
+import { createClient } from '@/lib/supabase/server';
+import { SITE_URL, liveUrl } from '@/lib/embed';
+import type { PublicSessionDetail } from '@pairux/shared-types';
+
+export const dynamic = 'force-dynamic';
+
+interface PageProps {
+ params: Promise<{ joinCode: string }>;
+}
+
+async function getSession(joinCode: string): Promise {
+ try {
+ const supabase = await createClient();
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
+ const { data, error } = await (supabase.rpc as any)('get_public_session', {
+ p_join_code: joinCode,
+ });
+ if (error) return null;
+ return (data as PublicSessionDetail[] | null)?.[0] ?? null;
+ } catch {
+ return null;
+ }
+}
+
+export async function generateMetadata({ params }: PageProps): Promise {
+ const { joinCode } = await params;
+ const session = await getSession(joinCode);
+ return {
+ title: session?.subject ?? 'PairUX player',
+ // The permalink at /l/ is the canonical, indexable page. The
+ // player is a bare duplicate of it, so keep it out of search results.
+ robots: { index: false, follow: false },
+ };
+}
+
+export default async function EmbedPlayerPage({ params }: PageProps) {
+ const { joinCode } = await params;
+ const session = await getSession(joinCode);
+ if (!session) notFound();
+
+ const permalink = liveUrl(session.join_code);
+ const title = session.subject ?? 'Untitled live';
+ const byline = session.channel_name ?? session.host_display_name ?? session.host_username ?? null;
+ // Absolute, because these links open out of the iframe into a new tab.
+ const channelUrl = session.channel_handle
+ ? `${SITE_URL}/@${session.channel_handle}`
+ : session.host_username
+ ? `${SITE_URL}/u/${session.host_username}`
+ : permalink;
+ const showRecording = Boolean(session.recording_url) && !session.is_live;
+
+ return (
+
+
+
+
+ {session.is_live && (
+
+
+ Live
+
+ )}
+
+
+ PairUX
+
+
+
+ );
+}
diff --git a/apps/web/src/app/l/[joinCode]/EmbedButton.tsx b/apps/web/src/app/l/[joinCode]/EmbedButton.tsx
new file mode 100644
index 00000000..e08b31aa
--- /dev/null
+++ b/apps/web/src/app/l/[joinCode]/EmbedButton.tsx
@@ -0,0 +1,74 @@
+'use client';
+
+import { useState } from 'react';
+import { Code2, Copy, Check } from 'lucide-react';
+
+interface EmbedButtonProps {
+ /** Ready-made