mirror of
https://github.com/mroxso/zelo-news.git
synced 2026-05-05 11:17:55 +02:00
Implement DVM TTS (NIP-90) components and hooks
Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
This commit is contained in:
173
src/components/DVMSelector.tsx
Normal file
173
src/components/DVMSelector.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
import { useState } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useDVMProviders } from '@/hooks/useDVMTTS';
|
||||
import { genUserName } from '@/lib/genUserName';
|
||||
|
||||
interface DVMSelectorProps {
|
||||
onSelect: (providerPubkey: string) => void;
|
||||
selectedProvider?: string | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DVMSelector({ onSelect, selectedProvider, className }: DVMSelectorProps) {
|
||||
const { data: providers, isLoading } = useDVMProviders();
|
||||
const [localSelected, setLocalSelected] = useState<string | null>(selectedProvider || null);
|
||||
|
||||
const handleSelect = (pubkey: string) => {
|
||||
setLocalSelected(pubkey);
|
||||
onSelect(pubkey);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-10 w-10 rounded-full" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-48" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!providers || providers.length === 0) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<Card>
|
||||
<CardContent className="p-6 text-center">
|
||||
<p className="text-muted-foreground">
|
||||
No TTS service providers found. Please try again later.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="space-y-3">
|
||||
{providers.map((provider) => {
|
||||
const isSelected = localSelected === provider.pubkey;
|
||||
const displayName = provider.name || genUserName(provider.pubkey);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={provider.pubkey}
|
||||
className={`cursor-pointer transition-all hover:shadow-md ${
|
||||
isSelected ? 'ring-2 ring-primary' : ''
|
||||
}`}
|
||||
onClick={() => handleSelect(provider.pubkey)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={provider.image} alt={displayName} />
|
||||
<AvatarFallback>{displayName[0]?.toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-semibold text-sm truncate">{displayName}</h4>
|
||||
{isSelected && (
|
||||
<Check className="h-4 w-4 text-primary flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
{provider.about && (
|
||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{provider.about}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{localSelected && (
|
||||
<div className="mt-4 text-xs text-muted-foreground text-center">
|
||||
Selected provider will process your TTS request
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple selector button that opens a dialog/drawer with DVM options
|
||||
*/
|
||||
interface DVMSelectorButtonProps {
|
||||
onConfirm: (providerPubkey: string) => void;
|
||||
buttonText?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function DVMSelectorButton({ onConfirm, buttonText = 'Select TTS Provider', disabled }: DVMSelectorButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (selectedProvider) {
|
||||
onConfirm(selectedProvider);
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)} disabled={disabled}>
|
||||
{buttonText}
|
||||
</Button>
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-lg max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<div className="p-6 border-b">
|
||||
<h3 className="text-lg font-semibold">Select TTS Service Provider</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Choose a Data Vending Machine to convert this article to speech
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<DVMSelector
|
||||
onSelect={setSelectedProvider}
|
||||
selectedProvider={selectedProvider}
|
||||
/>
|
||||
</div>
|
||||
<div className="p-6 border-t flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={!selectedProvider}
|
||||
className="flex-1"
|
||||
>
|
||||
Confirm Selection
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
257
src/components/TTSPlayer.tsx
Normal file
257
src/components/TTSPlayer.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { useState } from 'react';
|
||||
import { Play, Volume2, Loader2, Zap } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useTTSJobs, useRequestTTS, useJobFeedback } from '@/hooks/useDVMTTS';
|
||||
import { DVMSelectorButton } from '@/components/DVMSelector';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
|
||||
interface TTSPlayerProps {
|
||||
articleEvent: NostrEvent;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TTSPlayer({ articleEvent, className }: TTSPlayerProps) {
|
||||
const { user } = useCurrentUser();
|
||||
const { toast } = useToast();
|
||||
const { data: jobs, isLoading: jobsLoading } = useTTSJobs(articleEvent);
|
||||
const { mutate: requestTTS, isPending: isRequesting, activeJobRequestId, clearActiveJob } = useRequestTTS();
|
||||
const { data: feedback } = useJobFeedback(activeJobRequestId);
|
||||
const [selectedJobIndex, setSelectedJobIndex] = useState(0);
|
||||
|
||||
// Get the latest feedback status
|
||||
const latestFeedback = feedback?.[0];
|
||||
const isProcessing = latestFeedback?.status === 'processing';
|
||||
const paymentRequired = latestFeedback?.status === 'payment-required';
|
||||
const hasError = latestFeedback?.status === 'error';
|
||||
|
||||
const handleRequestTTS = (providerPubkey: string) => {
|
||||
if (!user) {
|
||||
toast({
|
||||
title: 'Login Required',
|
||||
description: 'Please log in to request text-to-speech conversion.',
|
||||
variant: 'destructive',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
requestTTS({
|
||||
articleEvent,
|
||||
providerPubkey,
|
||||
language: 'en', // Could be made configurable
|
||||
});
|
||||
};
|
||||
|
||||
const handlePayment = () => {
|
||||
if (latestFeedback?.bolt11) {
|
||||
// Open lightning URL
|
||||
window.open(`lightning:${latestFeedback.bolt11}`, '_blank');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectJob = (index: number) => {
|
||||
setSelectedJobIndex(index);
|
||||
};
|
||||
|
||||
if (jobsLoading) {
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-32" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const hasJobs = jobs && jobs.length > 0;
|
||||
const selectedJob = jobs?.[selectedJobIndex];
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Volume2 className="h-5 w-5" />
|
||||
Listen to Article
|
||||
</CardTitle>
|
||||
{hasJobs && (
|
||||
<Badge variant="secondary">
|
||||
{jobs.length} version{jobs.length > 1 ? 's' : ''} available
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Show active job status */}
|
||||
{activeJobRequestId && !hasError && (
|
||||
<div className="p-4 border rounded-lg bg-muted/50">
|
||||
<div className="flex items-center gap-3">
|
||||
{isProcessing || isRequesting ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-sm">Processing your request...</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{latestFeedback?.statusInfo || 'Waiting for service provider to process the article'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : paymentRequired ? (
|
||||
<>
|
||||
<Zap className="h-5 w-5 text-yellow-500" />
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-sm">Payment Required</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{latestFeedback?.statusInfo || 'The service provider requires payment to continue'}
|
||||
</p>
|
||||
{latestFeedback?.amountMillisats && (
|
||||
<p className="text-xs font-medium mt-1">
|
||||
Amount: {Math.floor(latestFeedback.amountMillisats / 1000)} sats
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{latestFeedback?.bolt11 && (
|
||||
<Button size="sm" onClick={handlePayment}>
|
||||
<Zap className="h-4 w-4 mr-2" />
|
||||
Pay
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<p className="text-sm">Waiting for response...</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearActiveJob}
|
||||
className="mt-3 w-full"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show error */}
|
||||
{hasError && latestFeedback && (
|
||||
<div className="p-4 border border-destructive rounded-lg bg-destructive/10">
|
||||
<p className="font-medium text-sm text-destructive">Error Processing Request</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{latestFeedback.statusInfo || latestFeedback.content || 'The service provider encountered an error'}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearActiveJob}
|
||||
className="mt-3"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasJobs ? (
|
||||
<>
|
||||
{/* Audio Player */}
|
||||
{selectedJob && (
|
||||
<div className="space-y-3">
|
||||
<audio
|
||||
key={selectedJob.audioUrl}
|
||||
controls
|
||||
className="w-full"
|
||||
src={selectedJob.audioUrl}
|
||||
preload="metadata"
|
||||
>
|
||||
Your browser does not support the audio element.
|
||||
</audio>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div>
|
||||
Provider: {selectedJob.providerNpub.slice(0, 12)}...
|
||||
</div>
|
||||
<div>
|
||||
{new Date(selectedJob.createdAt * 1000).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Multiple results selector */}
|
||||
{jobs.length > 1 && (
|
||||
<>
|
||||
<Separator />
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-3">Choose TTS Version</p>
|
||||
<div className="space-y-2">
|
||||
{jobs.map((job, index) => (
|
||||
<button
|
||||
key={job.event.id}
|
||||
onClick={() => handleSelectJob(index)}
|
||||
className={`w-full p-3 border rounded-lg text-left transition-all hover:border-primary ${
|
||||
selectedJobIndex === index ? 'border-primary bg-primary/5' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Play className="h-4 w-4" />
|
||||
<span className="text-sm">
|
||||
Version {index + 1}
|
||||
</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{new Date(job.createdAt * 1000).toLocaleDateString()}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
By {job.providerNpub.slice(0, 16)}...
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Request another TTS button */}
|
||||
<Separator />
|
||||
<div className="flex items-center justify-center">
|
||||
<DVMSelectorButton
|
||||
onConfirm={handleRequestTTS}
|
||||
buttonText="Request Another TTS"
|
||||
disabled={isRequesting || !user}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* No jobs yet - show request button */
|
||||
<div className="text-center space-y-4 py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No audio version available yet. Request a text-to-speech conversion from a DVM service provider.
|
||||
</p>
|
||||
<DVMSelectorButton
|
||||
onConfirm={handleRequestTTS}
|
||||
buttonText="Request TTS Conversion"
|
||||
disabled={isRequesting || !user}
|
||||
/>
|
||||
{!user && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Please log in to request TTS conversion
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
241
src/hooks/useDVMTTS.ts
Normal file
241
src/hooks/useDVMTTS.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNostr } from '@nostrify/react';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useToast } from '@/hooks/useToast';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
|
||||
/**
|
||||
* Hook to query available DVM service providers for TTS (kind 5250)
|
||||
* using NIP-89 announcements (kind 31990)
|
||||
*/
|
||||
export function useDVMProviders() {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['dvm-providers', 'tts'],
|
||||
queryFn: async (c) => {
|
||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]);
|
||||
|
||||
// Query for NIP-89 announcements for TTS (kind 5250)
|
||||
const events = await nostr.query(
|
||||
[{
|
||||
kinds: [31990],
|
||||
'#k': ['5250'], // TTS job kind
|
||||
limit: 50,
|
||||
}],
|
||||
{ signal }
|
||||
);
|
||||
|
||||
return events.map(event => {
|
||||
let metadata: { name?: string; about?: string; image?: string } = {};
|
||||
try {
|
||||
metadata = JSON.parse(event.content);
|
||||
} catch (error) {
|
||||
console.warn('Failed to parse DVM metadata:', error);
|
||||
}
|
||||
|
||||
return {
|
||||
event,
|
||||
pubkey: event.pubkey,
|
||||
npub: nip19.npubEncode(event.pubkey),
|
||||
name: metadata.name || 'Unknown DVM',
|
||||
about: metadata.about || '',
|
||||
image: metadata.image,
|
||||
};
|
||||
});
|
||||
},
|
||||
staleTime: 300000, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to query TTS job results for a specific article
|
||||
*/
|
||||
export function useTTSJobs(articleEvent: NostrEvent | null) {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['tts-jobs', articleEvent?.id],
|
||||
queryFn: async (c) => {
|
||||
if (!articleEvent) return [];
|
||||
|
||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]);
|
||||
|
||||
// For addressable events (kind 30023), we need to query using the 'a' tag
|
||||
const identifier = articleEvent.tags.find(([name]) => name === 'd')?.[1] || '';
|
||||
const aTag = `${articleEvent.kind}:${articleEvent.pubkey}:${identifier}`;
|
||||
|
||||
// Query for job results (kind 6250 = 5250 + 1000)
|
||||
const results = await nostr.query(
|
||||
[{
|
||||
kinds: [6250],
|
||||
'#a': [aTag],
|
||||
limit: 100,
|
||||
}],
|
||||
{ signal }
|
||||
);
|
||||
|
||||
// Filter out results that have valid audio URLs
|
||||
return results.filter(result => {
|
||||
// Check if content is a URL or if there's a URL in tags
|
||||
const content = result.content.trim();
|
||||
const urlTag = result.tags.find(([name]) => name === 'url')?.[1];
|
||||
return content.startsWith('http') || urlTag;
|
||||
}).map(result => {
|
||||
const amountTag = result.tags.find(([name]) => name === 'amount');
|
||||
const requestTag = result.tags.find(([name]) => name === 'request');
|
||||
const bolt11 = amountTag?.[2];
|
||||
|
||||
return {
|
||||
event: result,
|
||||
audioUrl: result.content.trim().startsWith('http')
|
||||
? result.content.trim()
|
||||
: result.tags.find(([name]) => name === 'url')?.[1] || '',
|
||||
provider: result.pubkey,
|
||||
providerNpub: nip19.npubEncode(result.pubkey),
|
||||
amountMillisats: amountTag?.[1] ? parseInt(amountTag[1]) : null,
|
||||
bolt11,
|
||||
requestEvent: requestTag?.[1],
|
||||
createdAt: result.created_at,
|
||||
};
|
||||
});
|
||||
},
|
||||
enabled: !!articleEvent?.id,
|
||||
staleTime: 60000, // 1 minute
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to query job feedback for a specific job request
|
||||
*/
|
||||
export function useJobFeedback(jobRequestId: string | null) {
|
||||
const { nostr } = useNostr();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['job-feedback', jobRequestId],
|
||||
queryFn: async (c) => {
|
||||
if (!jobRequestId) return [];
|
||||
|
||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]);
|
||||
|
||||
const feedbackEvents = await nostr.query(
|
||||
[{
|
||||
kinds: [7000],
|
||||
'#e': [jobRequestId],
|
||||
limit: 50,
|
||||
}],
|
||||
{ signal }
|
||||
);
|
||||
|
||||
return feedbackEvents.map(feedback => {
|
||||
const statusTag = feedback.tags.find(([name]) => name === 'status');
|
||||
const amountTag = feedback.tags.find(([name]) => name === 'amount');
|
||||
|
||||
return {
|
||||
event: feedback,
|
||||
provider: feedback.pubkey,
|
||||
status: statusTag?.[1] || 'unknown',
|
||||
statusInfo: statusTag?.[2] || '',
|
||||
amountMillisats: amountTag?.[1] ? parseInt(amountTag[1]) : null,
|
||||
bolt11: amountTag?.[2],
|
||||
content: feedback.content,
|
||||
createdAt: feedback.created_at,
|
||||
};
|
||||
}).sort((a, b) => b.createdAt - a.createdAt);
|
||||
},
|
||||
enabled: !!jobRequestId,
|
||||
refetchInterval: (query) => {
|
||||
// Keep refetching while we're waiting for results
|
||||
const data = query.state.data;
|
||||
if (!data || data.length === 0) return 5000; // 5 seconds
|
||||
|
||||
const latestStatus = data[0]?.status;
|
||||
if (latestStatus === 'processing' || latestStatus === 'payment-required') {
|
||||
return 10000; // 10 seconds
|
||||
}
|
||||
|
||||
return false; // Stop refetching
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to request a new TTS job
|
||||
*/
|
||||
export function useRequestTTS() {
|
||||
const { nostr } = useNostr();
|
||||
const { user } = useCurrentUser();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [activeJobRequestId, setActiveJobRequestId] = useState<string | null>(null);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async ({
|
||||
articleEvent,
|
||||
providerPubkey,
|
||||
language = 'en',
|
||||
}: {
|
||||
articleEvent: NostrEvent;
|
||||
providerPubkey?: string;
|
||||
language?: string;
|
||||
}) => {
|
||||
if (!user) {
|
||||
throw new Error('You must be logged in to request TTS');
|
||||
}
|
||||
|
||||
// Get article identifier for addressable event
|
||||
const identifier = articleEvent.tags.find(([name]) => name === 'd')?.[1] || '';
|
||||
const aTag = `${articleEvent.kind}:${articleEvent.pubkey}:${identifier}`;
|
||||
|
||||
// Create job request event (kind 5250)
|
||||
const eventTemplate = {
|
||||
kind: 5250,
|
||||
content: '',
|
||||
tags: [
|
||||
['i', aTag, 'event'], // Input is the article event
|
||||
['param', 'language', language],
|
||||
['output', 'audio/mpeg'], // Request MP3 output
|
||||
] as string[][],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
|
||||
// Add provider tag if specified
|
||||
if (providerPubkey) {
|
||||
eventTemplate.tags.push(['p', providerPubkey]);
|
||||
}
|
||||
|
||||
const signedEvent = await user.signer.signEvent(eventTemplate);
|
||||
await nostr.event(signedEvent);
|
||||
|
||||
return signedEvent;
|
||||
},
|
||||
onSuccess: (signedEvent, variables) => {
|
||||
setActiveJobRequestId(signedEvent.id);
|
||||
|
||||
// Invalidate TTS jobs query to refetch
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['tts-jobs', variables.articleEvent.id]
|
||||
});
|
||||
|
||||
toast({
|
||||
title: 'TTS Job Requested',
|
||||
description: 'Your text-to-speech job has been submitted. Service providers will process your request.',
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error instanceof Error ? error.message : 'Failed to request TTS',
|
||||
variant: 'destructive',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...mutation,
|
||||
activeJobRequestId,
|
||||
clearActiveJob: () => setActiveJobRequestId(null),
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { MarkdownContent } from '@/components/MarkdownContent';
|
||||
import { CommentsSection } from '@/components/comments/CommentsSection';
|
||||
import { ZapButton } from '@/components/ZapButton';
|
||||
import { BookmarkButton } from '@/components/BookmarkButton';
|
||||
import { TTSPlayer } from '@/components/TTSPlayer';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -215,6 +216,14 @@ export default function BlogPostPage() {
|
||||
|
||||
<Separator className="my-8" />
|
||||
|
||||
{/* TTS Player section */}
|
||||
<TTSPlayer
|
||||
articleEvent={post}
|
||||
className="mb-8"
|
||||
/>
|
||||
|
||||
<Separator className="my-8" />
|
||||
|
||||
{/* Comments section */}
|
||||
<CommentsSection
|
||||
root={post}
|
||||
|
||||
Reference in New Issue
Block a user