feat: add audio asset support with recording and upload functionality

- Implemented audio asset handling in the API, including download and deletion routes.
- Added audio content type mappings and extraction functions for audio file names and keys.
- Enhanced the asset management UI to support audio uploads, including recording capabilities.
- Updated the database schema to accommodate audio assets with new enum values.
- Integrated audio playback features in the asset list and comment sections.
- Improved user experience with drag-and-drop support for audio files.
This commit is contained in:
Yusuf İpek
2026-02-28 11:01:10 +03:00
parent 9dde7dce44
commit 975ff603e1
14 changed files with 615 additions and 43 deletions
+5 -5
View File
@@ -1,7 +1,7 @@
'use client';
import { memo, type ReactNode } from 'react';
import { Download, Image as ImageIcon, Loader2, Play, Trash2 } from 'lucide-react';
import { Download, Image as ImageIcon, Loader2, Play, Trash2, Volume2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
@@ -103,15 +103,15 @@ export const AssetListSection = memo(function AssetListSection({
size="icon"
variant="outline"
className="h-7 w-7"
title={asset.kind === 'VIDEO' ? 'Play video' : 'View image'}
aria-label={asset.kind === 'VIDEO' ? 'Play video' : 'View image'}
title={asset.kind === 'VIDEO' ? 'Play video' : asset.kind === 'AUDIO' ? 'Play recording' : 'View image'}
aria-label={asset.kind === 'VIDEO' ? 'Play video' : asset.kind === 'AUDIO' ? 'Play recording' : 'View image'}
onClick={() => onViewAsset(asset)}
>
{asset.kind === 'IMAGE' ? <ImageIcon className="h-3 w-3" /> : <Play className="h-3 w-3" />}
{asset.kind === 'IMAGE' ? <ImageIcon className="h-3 w-3" /> : asset.kind === 'AUDIO' ? <Volume2 className="h-3 w-3" /> : <Play className="h-3 w-3" />}
</Button>
{canDownloadAssets && asset.provider !== 'YOUTUBE' && (
asset.provider === 'BUNNY' ? (
asset.provider === 'BUNNY' && asset.kind !== 'AUDIO' ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
+358 -14
View File
@@ -1,9 +1,9 @@
'use client';
import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import * as tus from 'tus-js-client';
import { toast } from 'sonner';
import { Download, FileVideo, Image as ImageIcon, Loader2, Play, UploadCloud, X, Youtube } from 'lucide-react';
import { Download, FileVideo, Image as ImageIcon, Loader2, Mic, Pause, Play, Square, UploadCloud, Volume2, X, Youtube } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
@@ -20,7 +20,14 @@ import { BunnyPreviewPlayer, type BunnyPreviewPlayerHandle } from '@/components/
import { AssetListSection } from '@/components/video-page/asset-list-section';
import type { VideoAsset } from '@/components/video-page/types';
import { extractPastedImageFile, validateImageFile } from '@/components/video-page/image-upload-utils';
import { useCommentMedia } from '@/components/video-page/hooks/use-comment-media';
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
import { cn } from '@/lib/utils';
function formatTime(seconds: number): string {
const s = Math.floor(seconds);
return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
}
interface AssetsPaneProps {
videoId: string;
@@ -31,9 +38,9 @@ interface AssetsPaneProps {
activeDownloadAssetId: string | null;
canUploadAssets: boolean;
canDownloadAssets: boolean;
getGuestUploadToken: (intent: 'image') => Promise<string | null>;
getGuestUploadToken: (intent: 'image' | 'audio') => Promise<string | null>;
createAsset: (payload: {
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY';
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY' | 'R2_AUDIO';
displayName?: string;
sourceUrl: string;
providerVideoId?: string;
@@ -68,7 +75,7 @@ export const AssetsPane = memo(function AssetsPane({
highlightedAssetId,
onHighlightedAssetHandled,
}: AssetsPaneProps) {
const [uploadTab, setUploadTab] = useState<'image' | 'youtube' | 'bunny'>('image');
const [uploadTab, setUploadTab] = useState<'image' | 'youtube' | 'bunny' | 'voice'>('image');
const [imageTitle, setImageTitle] = useState('');
const [pendingImageFile, setPendingImageFile] = useState<File | null>(null);
const [youtubeUrl, setYoutubeUrl] = useState('');
@@ -91,6 +98,25 @@ export const AssetsPane = memo(function AssetsPane({
const imageInputRef = useRef<HTMLInputElement>(null);
const bunnyInputRef = useRef<HTMLInputElement>(null);
// Voice recording state
const [voiceTitle, setVoiceTitle] = useState('');
const [isRecording, setIsRecording] = useState(false);
const [recordingTime, setRecordingTime] = useState(0);
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
const [audioBlobUrl, setAudioBlobUrl] = useState<string | null>(null);
const [pendingAudioFile, setPendingAudioFile] = useState<File | null>(null);
const [isUploadingVoice, setIsUploadingVoice] = useState(false);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<BlobPart[]>([]);
const recordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Drag-drop state
const [isDragOver, setIsDragOver] = useState(false);
const dragCounterRef = useRef(0);
// Audio playback for asset preview dialog and recording preview
const { playingVoiceId, voiceProgress, voiceCurrentTime, voicePlaybackRate, playVoice, stopVoice, toggleVoiceSpeed } = useCommentMedia();
const sortedAssets = useMemo(() => {
return [...assets].sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt));
}, [assets]);
@@ -227,7 +253,7 @@ export const AssetsPane = memo(function AssetsPane({
setBunnyProcessingByAssetId((prev) => (prev[selectedAsset.id] ? prev : { ...prev, [selectedAsset.id]: true }));
}, [bunnyReadyByAssetId, selectedAsset]);
const handleImageUpload = async (file: File) => {
const handleImageUpload = useCallback(async (file: File) => {
if (!file) return;
const imageError = validateImageFile(file);
@@ -266,7 +292,7 @@ export const AssetsPane = memo(function AssetsPane({
console.error('Failed to upload image asset:', error);
toast.error('Failed to upload image');
}
};
}, [videoId, getGuestUploadToken, createAsset, imageTitle]);
const handleImageFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
@@ -307,9 +333,7 @@ export const AssetsPane = memo(function AssetsPane({
}
};
const handleBunnyUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const handleBunnyFileUpload = useCallback(async (file: File) => {
if (!file.type.startsWith('video/')) {
toast.error('Please select a video file');
return;
@@ -403,6 +427,12 @@ export const AssetsPane = memo(function AssetsPane({
setIsUploadingBunny(false);
setBunnyProgress(0);
}
}, [videoId, bunnyTitle, bunnyCdnHostname, createAsset]);
const handleBunnyUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
await handleBunnyFileUpload(file);
};
const handleBunnyThumbnailError = (assetId: string) => {
@@ -425,7 +455,161 @@ export const AssetsPane = memo(function AssetsPane({
});
};
const startRecording = useCallback(async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: 'audio/webm';
const recorder = new MediaRecorder(stream, { mimeType });
audioChunksRef.current = [];
recorder.ondataavailable = (e) => {
if (e.data.size > 0) audioChunksRef.current.push(e.data);
};
recorder.onstop = () => {
const blob = new Blob(audioChunksRef.current, { type: mimeType });
setAudioBlob(blob);
setAudioBlobUrl((prev) => {
if (prev) URL.revokeObjectURL(prev);
return URL.createObjectURL(blob);
});
stream.getTracks().forEach((t) => t.stop());
};
mediaRecorderRef.current = recorder;
recorder.start(100);
setIsRecording(true);
setRecordingTime(0);
recordingTimerRef.current = setInterval(() => setRecordingTime((t) => t + 1), 1000);
} catch {
toast.error('Could not access microphone');
}
}, []);
const stopRecording = useCallback(() => {
mediaRecorderRef.current?.stop();
mediaRecorderRef.current = null;
setIsRecording(false);
if (recordingTimerRef.current) {
clearInterval(recordingTimerRef.current);
recordingTimerRef.current = null;
}
}, []);
const cancelRecording = useCallback(() => {
mediaRecorderRef.current?.stop();
mediaRecorderRef.current?.stream?.getTracks().forEach((t) => t.stop());
mediaRecorderRef.current = null;
setIsRecording(false);
setRecordingTime(0);
setAudioBlob(null);
setAudioBlobUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return null; });
setPendingAudioFile(null);
if (recordingTimerRef.current) {
clearInterval(recordingTimerRef.current);
recordingTimerRef.current = null;
}
}, []);
const handleVoiceUpload = useCallback(async () => {
const uploadSource = pendingAudioFile ?? audioBlob;
if (!uploadSource) return;
setIsUploadingVoice(true);
try {
const formData = new FormData();
if (pendingAudioFile) {
formData.append('audio', pendingAudioFile);
} else {
formData.append('audio', audioBlob!, 'recording.webm');
}
formData.append('videoId', videoId);
const guestUploadToken = await getGuestUploadToken('audio');
if (guestUploadToken) formData.append('uploadToken', guestUploadToken);
const uploadRes = await fetch('/api/upload/audio', {
method: 'POST',
body: formData,
});
const uploadPayload = (await uploadRes.json().catch(() => null)) as { data?: { url?: string }; error?: string } | null;
const uploadedUrl = uploadPayload?.data?.url;
if (!uploadRes.ok || !uploadedUrl) {
toast.error(uploadPayload?.error || 'Failed to upload voice recording');
return;
}
const fallbackName = pendingAudioFile
? pendingAudioFile.name.replace(/\.[^/.]+$/, '')
: 'Voice Recording';
await createAsset({
provider: 'R2_AUDIO',
sourceUrl: uploadedUrl,
displayName: voiceTitle.trim() || fallbackName,
});
setVoiceTitle('');
setAudioBlob(null);
setAudioBlobUrl((prev) => { if (prev) URL.revokeObjectURL(prev); return null; });
setPendingAudioFile(null);
} catch {
toast.error('Failed to upload voice recording');
} finally {
setIsUploadingVoice(false);
}
}, [pendingAudioFile, audioBlob, videoId, getGuestUploadToken, createAsset, voiceTitle]);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
if (!canUploadAssets) return;
dragCounterRef.current += 1;
if (e.dataTransfer.types.includes('Files')) setIsDragOver(true);
}, [canUploadAssets]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
dragCounterRef.current -= 1;
if (dragCounterRef.current === 0) setIsDragOver(false);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
}, []);
const handleDrop = useCallback(async (e: React.DragEvent) => {
e.preventDefault();
dragCounterRef.current = 0;
setIsDragOver(false);
if (!canUploadAssets) return;
const file = Array.from(e.dataTransfer.files)[0];
if (!file) return;
if (file.type.startsWith('image/')) {
const imageError = validateImageFile(file);
if (imageError) { toast.error(imageError); return; }
// Stage the file so the user can optionally set a name before uploading
setUploadTab('image');
setPendingImageFile(file);
} else if (file.type.startsWith('video/')) {
// Videos upload immediately (large files, no staging)
setUploadTab('bunny');
await handleBunnyFileUpload(file);
} else if (file.type.startsWith('audio/')) {
// Stage the file so the user can optionally set a name before uploading
setUploadTab('voice');
setPendingAudioFile(file);
} else {
toast.error('Unsupported file type. Drop an image, video, or audio file.');
}
}, [canUploadAssets, handleBunnyFileUpload]);
const renderAssetPreview = (asset: VideoAsset) => {
if (asset.kind === 'AUDIO') {
return (
<div className="h-24 w-36 rounded border bg-muted flex flex-col items-center justify-center gap-1">
<Volume2 className="h-6 w-6 text-muted-foreground" />
<span className="text-[10px] text-muted-foreground font-medium">Voice Recording</span>
</div>
);
}
if (asset.kind === 'IMAGE') {
const imageSrc = asset.thumbnailUrl || asset.sourceUrl;
return (
@@ -501,6 +685,10 @@ export const AssetsPane = memo(function AssetsPane({
setPreviewImageTitle(asset.displayName);
return;
}
if (asset.kind === 'AUDIO') {
setSelectedAsset(asset);
return;
}
if (asset.provider === 'BUNNY' && !bunnyReadyByAssetId[asset.id]) {
setBunnyProcessingByAssetId((prev) => (prev[asset.id] ? prev : { ...prev, [asset.id]: true }));
}
@@ -513,7 +701,14 @@ export const AssetsPane = memo(function AssetsPane({
: false;
return (
<div className="space-y-4" onPaste={handleImagePaste}>
<div
className="space-y-4"
onPaste={handleImagePaste}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="font-medium">Assets</span>
@@ -522,12 +717,19 @@ export const AssetsPane = memo(function AssetsPane({
</div>
{canUploadAssets ? (
<div className="rounded-lg border p-3 space-y-3">
<Tabs value={uploadTab} onValueChange={(value) => setUploadTab(value as 'image' | 'youtube' | 'bunny')}>
<TabsList className="grid w-full grid-cols-3">
<div className={cn('rounded-lg border p-3 space-y-3 relative transition-colors', isDragOver && 'border-primary bg-primary/5')}>
{isDragOver && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 rounded-lg bg-primary/10 border-2 border-dashed border-primary pointer-events-none">
<UploadCloud className="h-8 w-8 text-primary" />
<span className="text-sm font-medium text-primary">Drop to upload</span>
</div>
)}
<Tabs value={uploadTab} onValueChange={(value) => setUploadTab(value as 'image' | 'youtube' | 'bunny' | 'voice')}>
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="image">Image</TabsTrigger>
<TabsTrigger value="youtube">YouTube</TabsTrigger>
<TabsTrigger value="bunny">Video</TabsTrigger>
<TabsTrigger value="voice">Voice</TabsTrigger>
</TabsList>
</Tabs>
@@ -636,6 +838,111 @@ export const AssetsPane = memo(function AssetsPane({
)}
</div>
)}
{uploadTab === 'voice' && (
<div className="space-y-2">
<Input
placeholder="Optional name for this recording"
value={voiceTitle}
onChange={(e) => setVoiceTitle(e.target.value)}
disabled={isRecording || isUploadingVoice}
/>
{pendingAudioFile ? (
<div className="space-y-2">
<div className="rounded-md border px-2 py-1.5 text-xs flex items-center justify-between gap-2">
<span className="truncate">Attached: {pendingAudioFile.name}</span>
<Button
type="button"
size="sm"
variant="ghost"
className="h-6 px-2"
onClick={() => setPendingAudioFile(null)}
>
Clear
</Button>
</div>
<Button
className="w-full"
disabled={isUploadingVoice || isCreatingAsset}
onClick={handleVoiceUpload}
>
{isUploadingVoice ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <UploadCloud className="h-4 w-4 mr-2" />}
{isUploadingVoice ? 'Uploading...' : 'Upload File'}
</Button>
</div>
) : isRecording ? (
<div className="flex items-center gap-2">
<div className="flex-1 flex items-center gap-2 rounded-md border px-3 py-2 text-sm">
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500" />
</span>
<span className="text-red-500 font-medium">Recording</span>
<span className="ml-auto tabular-nums text-muted-foreground">
{String(Math.floor(recordingTime / 60)).padStart(2, '0')}:{String(recordingTime % 60).padStart(2, '0')}
</span>
</div>
<Button size="icon" variant="outline" className="h-9 w-9 shrink-0" title="Stop recording" onClick={stopRecording}>
<Square className="h-3.5 w-3.5 fill-current" />
</Button>
<Button size="icon" variant="ghost" className="h-9 w-9 shrink-0" title="Cancel recording" onClick={cancelRecording}>
<X className="h-4 w-4" />
</Button>
</div>
) : audioBlob ? (
<div className="space-y-2">
<div className="flex items-center gap-2 p-2 bg-muted rounded">
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => audioBlobUrl && playVoice('recording-preview', audioBlobUrl, recordingTime)}
>
{playingVoiceId === 'recording-preview' ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<div className="flex-1 h-2 bg-primary/20 rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full"
style={{ width: playingVoiceId === 'recording-preview' ? `${voiceProgress}%` : '0%' }}
/>
</div>
<span className="text-xs text-muted-foreground tabular-nums shrink-0">
{playingVoiceId === 'recording-preview'
? `${formatTime(voiceCurrentTime)} / ${formatTime(recordingTime)}`
: formatTime(recordingTime)}
</span>
{playingVoiceId === 'recording-preview' && (
<button
onClick={toggleVoiceSpeed}
className="text-[10px] font-bold px-1 py-0.5 rounded bg-muted hover:bg-muted-foreground/20 tabular-nums shrink-0"
>
{voicePlaybackRate}x
</button>
)}
</div>
<div className="flex gap-2">
<Button
className="flex-1"
disabled={isUploadingVoice || isCreatingAsset}
onClick={handleVoiceUpload}
>
{isUploadingVoice ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <UploadCloud className="h-4 w-4 mr-2" />}
{isUploadingVoice ? 'Uploading...' : 'Upload Recording'}
</Button>
<Button variant="outline" size="icon" className="h-9 w-9 shrink-0" title="Discard and re-record" onClick={cancelRecording}>
<X className="h-4 w-4" />
</Button>
</div>
</div>
) : (
<Button variant="outline" className="w-full" onClick={startRecording} disabled={isUploadingVoice || isCreatingAsset}>
<Mic className="h-4 w-4 mr-2" />
Start Recording
</Button>
)}
<p className="text-xs text-muted-foreground">Or drag an audio file anywhere onto this panel.</p>
</div>
)}
</div>
) : (
<div className="rounded-lg border p-3 text-xs text-muted-foreground">
@@ -672,6 +979,43 @@ export const AssetsPane = memo(function AssetsPane({
}}
/>
<Dialog open={selectedAsset?.kind === 'AUDIO'} onOpenChange={(open) => { if (!open) { stopVoice(); setSelectedAsset(null); } }}>
<DialogContent className="max-w-sm">
<DialogTitle>{selectedAsset?.displayName || 'Voice Recording'}</DialogTitle>
{selectedAsset?.sourceUrl ? (
<div className="flex items-center gap-2 p-2 bg-muted rounded">
<Button
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0"
onClick={() => selectedAsset.sourceUrl && playVoice(selectedAsset.id, selectedAsset.sourceUrl)}
>
{playingVoiceId === selectedAsset?.id ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<div className="flex-1 h-2 bg-primary/20 rounded-full overflow-hidden">
<div
className="h-full bg-primary rounded-full"
style={{ width: playingVoiceId === selectedAsset?.id ? `${voiceProgress}%` : '0%' }}
/>
</div>
<span className="text-xs text-muted-foreground tabular-nums shrink-0">
{playingVoiceId === selectedAsset?.id ? formatTime(voiceCurrentTime) : '00:00'}
</span>
{playingVoiceId === selectedAsset?.id && (
<button
onClick={toggleVoiceSpeed}
className="text-[10px] font-bold px-1 py-0.5 rounded bg-muted hover:bg-muted-foreground/20 tabular-nums shrink-0"
>
{voicePlaybackRate}x
</button>
)}
</div>
) : (
<p className="text-sm text-muted-foreground">Audio preview unavailable.</p>
)}
</DialogContent>
</Dialog>
<Dialog open={selectedAsset?.kind === 'VIDEO'} onOpenChange={(open) => !open && setSelectedAsset(null)}>
<DialogContent
showCloseButton={false}
+6 -4
View File
@@ -1,7 +1,7 @@
'use client';
import React from 'react';
import { Image as ImageIcon, Video } from 'lucide-react';
import { Image as ImageIcon, Video, Volume2 } from 'lucide-react';
import type { VideoAsset } from '@/components/video-page/types';
const URL_REGEX = /(https?:\/\/[^\s]+)/g;
@@ -50,7 +50,7 @@ export function CommentRichText({ text, onAssetMentionClick, assets = [] }: Comm
const assetId = match[2] || '';
const matchedAsset = assets.find((asset) => asset.id === assetId);
const label = matchedAsset?.displayName || fallbackLabel;
const isVideoAsset = matchedAsset?.kind === 'VIDEO';
const assetKind = matchedAsset?.kind;
nodes.push(
<button
@@ -65,12 +65,14 @@ export function CommentRichText({ text, onAssetMentionClick, assets = [] }: Comm
>
<span
className={
isVideoAsset
assetKind === 'VIDEO'
? 'inline-flex h-4 w-4 shrink-0 items-center justify-center rounded bg-violet-500/25 text-violet-200'
: assetKind === 'AUDIO'
? 'inline-flex h-4 w-4 shrink-0 items-center justify-center rounded bg-blue-500/25 text-blue-200'
: 'inline-flex h-4 w-4 shrink-0 items-center justify-center rounded bg-emerald-500/25 text-emerald-200'
}
>
{isVideoAsset ? <Video className="h-2.5 w-2.5" /> : <ImageIcon className="h-2.5 w-2.5" />}
{assetKind === 'VIDEO' ? <Video className="h-2.5 w-2.5" /> : assetKind === 'AUDIO' ? <Volume2 className="h-2.5 w-2.5" /> : <ImageIcon className="h-2.5 w-2.5" />}
</span>
<span className="truncate max-w-[190px] sm:max-w-[240px]">
@{label}
@@ -86,6 +86,17 @@ export function useCommentMedia() {
void audio.play();
}, [playingVoiceId, voicePlaybackRate, startVoiceTracking, stopVoiceTracking]);
const stopVoice = useCallback(() => {
if (audioPlayerRef.current) {
audioPlayerRef.current.pause();
audioPlayerRef.current = null;
}
stopVoiceTracking();
setPlayingVoiceId(null);
setVoiceProgress(0);
setVoiceCurrentTime(0);
}, [stopVoiceTracking]);
const toggleVoiceSpeed = useCallback(() => {
setVoicePlaybackRate((prev) => {
const next = prev === 1 ? 2 : 1;
@@ -112,6 +123,7 @@ export function useCommentMedia() {
voiceCurrentTime,
voicePlaybackRate,
playVoice,
stopVoice,
toggleVoiceSpeed,
};
}
@@ -7,7 +7,7 @@ import type { VideoAsset } from '@/components/video-page/types';
type BunnyDownloadPreference = 'original' | 'compressed';
type CreateAssetPayload = {
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY';
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY' | 'R2_AUDIO';
displayName?: string;
sourceUrl: string;
providerVideoId?: string;
@@ -240,7 +240,7 @@ export function useVideoAssets({
}
}, [canDownloadAssets, videoId]);
const getGuestUploadToken = useCallback(async (intent: 'image') => {
const getGuestUploadToken = useCallback(async (intent: 'image' | 'audio') => {
if (isAuthenticated) return null;
const response = await fetch(`/api/watch/${videoId}/upload-token`, {
method: 'POST',
+2 -2
View File
@@ -21,8 +21,8 @@ export interface CommentTag {
export interface VideoAsset {
id: string;
videoId: string;
kind: 'IMAGE' | 'VIDEO';
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY';
kind: 'IMAGE' | 'VIDEO' | 'AUDIO';
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY' | 'R2_AUDIO';
displayName: string;
sourceUrl: string | null;
providerVideoId: string | null;