mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
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:
@@ -47,3 +47,6 @@ next-env.d.ts
|
|||||||
PROGRESS.md
|
PROGRESS.md
|
||||||
OPTIMIZATIONS.md
|
OPTIMIZATIONS.md
|
||||||
.kilocode
|
.kilocode
|
||||||
|
|
||||||
|
# Claude doesn't respect AGENTS.md and I don't want double AGENTS.md files on the repo.
|
||||||
|
CLAUDE.md
|
||||||
@@ -15,7 +15,74 @@ import {
|
|||||||
} from '@/lib/guest-upload-token';
|
} from '@/lib/guest-upload-token';
|
||||||
|
|
||||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||||
const ALLOWED_TYPES = ['audio/webm', 'audio/ogg', 'audio/mp4', 'audio/mpeg', 'audio/wav'];
|
|
||||||
|
// Canonical MIME types accepted
|
||||||
|
const ALLOWED_TYPES = new Set(['audio/webm', 'audio/ogg', 'audio/opus', 'audio/mp4', 'audio/mpeg', 'audio/wav']);
|
||||||
|
|
||||||
|
// Normalize known MIME aliases to canonical values
|
||||||
|
const MIME_ALIASES: Record<string, string> = {
|
||||||
|
'audio/wave': 'audio/wav',
|
||||||
|
'audio/vnd.wave': 'audio/wav',
|
||||||
|
'audio/x-wav': 'audio/wav',
|
||||||
|
'audio/x-pn-wav': 'audio/wav',
|
||||||
|
'audio/mp3': 'audio/mpeg',
|
||||||
|
'audio/x-mpeg': 'audio/mpeg',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map canonical MIME to fallback file extension
|
||||||
|
const MIME_TO_EXT: Record<string, string> = {
|
||||||
|
'audio/webm': 'webm',
|
||||||
|
'audio/ogg': 'ogg',
|
||||||
|
'audio/opus': 'opus',
|
||||||
|
'audio/mp4': 'm4a',
|
||||||
|
'audio/mpeg': 'mp3',
|
||||||
|
'audio/wav': 'wav',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Safe extensions to preserve from original filename (prevents path traversal, allows known types)
|
||||||
|
// Intentionally excludes flac/aac: they have no corresponding MIME in ALLOWED_TYPES and are
|
||||||
|
// never produced by MediaRecorder, so accepting them would create extension/MIME mismatches.
|
||||||
|
const SAFE_AUDIO_EXTENSIONS = new Set(['webm', 'ogg', 'opus', 'mp3', 'm4a', 'mp4', 'wav']);
|
||||||
|
|
||||||
|
// Reject content that looks like HTML/XML/script regardless of the declared MIME type.
|
||||||
|
function isHtmlContent(bytes: Buffer): boolean {
|
||||||
|
const snippet = bytes.toString('latin1', 0, Math.min(bytes.length, 512)).trimStart().slice(0, 50).toLowerCase();
|
||||||
|
return (
|
||||||
|
snippet.startsWith('<!doctype') ||
|
||||||
|
snippet.startsWith('<html') ||
|
||||||
|
snippet.startsWith('<?xml') ||
|
||||||
|
snippet.startsWith('<script') ||
|
||||||
|
snippet.startsWith('<svg')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify that the first bytes of the file match known audio container signatures.
|
||||||
|
function hasValidAudioMagicBytes(header: Buffer, mimeType: string): boolean {
|
||||||
|
if (header.length < 8) return false;
|
||||||
|
switch (mimeType) {
|
||||||
|
// WebM / Matroska: EBML header 1a 45 df a3
|
||||||
|
case 'audio/webm':
|
||||||
|
return header[0] === 0x1a && header[1] === 0x45 && header[2] === 0xdf && header[3] === 0xa3;
|
||||||
|
// OGG container (covers ogg vorbis and opus)
|
||||||
|
case 'audio/ogg':
|
||||||
|
case 'audio/opus':
|
||||||
|
return header[0] === 0x4f && header[1] === 0x67 && header[2] === 0x67 && header[3] === 0x53; // "OggS"
|
||||||
|
// MPEG audio: ID3 tag header or raw MPEG sync frame
|
||||||
|
case 'audio/mpeg': {
|
||||||
|
const hasId3 = header[0] === 0x49 && header[1] === 0x44 && header[2] === 0x33; // "ID3"
|
||||||
|
const hasMpegSync = header[0] === 0xff && (header[1] & 0xe0) === 0xe0;
|
||||||
|
return hasId3 || hasMpegSync;
|
||||||
|
}
|
||||||
|
// MP4 / M4A: ISO base media file; "ftyp" box starts at offset 4
|
||||||
|
case 'audio/mp4':
|
||||||
|
return header[4] === 0x66 && header[5] === 0x74 && header[6] === 0x79 && header[7] === 0x70; // "ftyp"
|
||||||
|
// WAV: RIFF header
|
||||||
|
case 'audio/wav':
|
||||||
|
return header[0] === 0x52 && header[1] === 0x49 && header[2] === 0x46 && header[3] === 0x46; // "RIFF"
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -101,14 +168,18 @@ export async function POST(request: NextRequest) {
|
|||||||
return apiErrors.badRequest('File too large. Maximum size is 10MB.');
|
return apiErrors.badRequest('File too large. Maximum size is 10MB.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check content type
|
// Normalize content type: strip codec params, then resolve aliases
|
||||||
const contentType = file.type || 'audio/webm';
|
const rawContentType = file.type || 'audio/webm';
|
||||||
if (!ALLOWED_TYPES.includes(contentType)) {
|
const strippedType = rawContentType.split(';')[0].trim().toLowerCase();
|
||||||
return apiErrors.badRequest(`Unsupported audio format: ${contentType}`);
|
const contentType = MIME_ALIASES[strippedType] ?? strippedType;
|
||||||
|
if (!ALLOWED_TYPES.has(contentType)) {
|
||||||
|
return apiErrors.badRequest(`Unsupported audio format: ${rawContentType}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate unique filename
|
// Prefer the original file extension when it's a known safe type (e.g. preserve .opus, .mp3)
|
||||||
const ext = contentType.split('/')[1] || 'webm';
|
// Fall back to MIME-derived extension for blobs without a real name (e.g. MediaRecorder output)
|
||||||
|
const origExt = (file.name.split('.').pop() ?? '').toLowerCase();
|
||||||
|
const ext = SAFE_AUDIO_EXTENSIONS.has(origExt) ? origExt : (MIME_TO_EXT[contentType] ?? 'webm');
|
||||||
const filename = `${randomUUID()}.${ext}`;
|
const filename = `${randomUUID()}.${ext}`;
|
||||||
const key = `voice/${filename}`;
|
const key = `voice/${filename}`;
|
||||||
|
|
||||||
@@ -116,6 +187,14 @@ export async function POST(request: NextRequest) {
|
|||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
const buffer = Buffer.from(arrayBuffer);
|
const buffer = Buffer.from(arrayBuffer);
|
||||||
|
|
||||||
|
// Validate file content against magic bytes — rejects HTML/scripts masquerading as audio
|
||||||
|
if (isHtmlContent(buffer)) {
|
||||||
|
return apiErrors.badRequest('File content does not match an audio format');
|
||||||
|
}
|
||||||
|
if (!hasValidAudioMagicBytes(buffer.slice(0, 16), contentType)) {
|
||||||
|
return apiErrors.badRequest('File content does not match the declared audio format');
|
||||||
|
}
|
||||||
|
|
||||||
// Upload to R2
|
// Upload to R2
|
||||||
await r2Client.send(
|
await r2Client.send(
|
||||||
new PutObjectCommand({
|
new PutObjectCommand({
|
||||||
|
|||||||
@@ -7,19 +7,31 @@ import { fetchWithTimeout, resolveBunnyDownloadSource } from '@/lib/bunny-downlo
|
|||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import {
|
import {
|
||||||
extractImageFileNameFromProxyUrl,
|
extractImageFileNameFromProxyUrl,
|
||||||
|
extractAudioFileNameFromProxyUrl,
|
||||||
getVideoAssetAccessContext,
|
getVideoAssetAccessContext,
|
||||||
} from '@/lib/video-assets';
|
} from '@/lib/video-assets';
|
||||||
|
|
||||||
type RouteParams = { params: Promise<{ videoId: string; assetId: string }> };
|
type RouteParams = { params: Promise<{ videoId: string; assetId: string }> };
|
||||||
type BunnySourcePreference = 'auto' | 'original' | 'compressed';
|
type BunnySourcePreference = 'auto' | 'original' | 'compressed';
|
||||||
|
|
||||||
const CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
|
const IMAGE_CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
|
||||||
jpg: 'image/jpeg',
|
jpg: 'image/jpeg',
|
||||||
jpeg: 'image/jpeg',
|
jpeg: 'image/jpeg',
|
||||||
png: 'image/png',
|
png: 'image/png',
|
||||||
webp: 'image/webp',
|
webp: 'image/webp',
|
||||||
gif: 'image/gif',
|
gif: 'image/gif',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const AUDIO_CONTENT_TYPE_BY_EXTENSION: Record<string, string> = {
|
||||||
|
webm: 'audio/webm',
|
||||||
|
ogg: 'audio/ogg',
|
||||||
|
opus: 'audio/ogg',
|
||||||
|
mp4: 'audio/mp4',
|
||||||
|
m4a: 'audio/mp4',
|
||||||
|
mpeg: 'audio/mpeg',
|
||||||
|
mp3: 'audio/mpeg',
|
||||||
|
wav: 'audio/wav',
|
||||||
|
};
|
||||||
const BUNNY_ALLOWED_QUALITIES = new Set([2160, 1440, 1080, 720, 480, 360, 240]);
|
const BUNNY_ALLOWED_QUALITIES = new Set([2160, 1440, 1080, 720, 480, 360, 240]);
|
||||||
|
|
||||||
function sanitizeFileName(value: string): string {
|
function sanitizeFileName(value: string): string {
|
||||||
@@ -47,7 +59,7 @@ function buildContentDisposition(fileNameWithExt: string): string {
|
|||||||
|
|
||||||
function imageContentTypeFromFileName(fileName: string): string {
|
function imageContentTypeFromFileName(fileName: string): string {
|
||||||
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
||||||
return CONTENT_TYPE_BY_EXTENSION[ext] || 'application/octet-stream';
|
return IMAGE_CONTENT_TYPE_BY_EXTENSION[ext] || 'application/octet-stream';
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/videos/[videoId]/assets/[assetId]/download
|
// GET /api/videos/[videoId]/assets/[assetId]/download
|
||||||
@@ -101,6 +113,29 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (asset.provider === VideoAssetProvider.R2_AUDIO) {
|
||||||
|
const fileName = extractAudioFileNameFromProxyUrl(asset.sourceUrl);
|
||||||
|
if (!fileName) return apiErrors.badRequest('Invalid audio asset URL');
|
||||||
|
const key = `voice/${fileName}`;
|
||||||
|
const ext = fileName.includes('.') ? fileName.slice(fileName.lastIndexOf('.')) : '.webm';
|
||||||
|
const downloadName = `${sanitizeFileName(asset.displayName)}${ext}`;
|
||||||
|
const contentDisposition = buildContentDisposition(downloadName);
|
||||||
|
const extKey = ext.replace('.', '');
|
||||||
|
const contentType = AUDIO_CONTENT_TYPE_BY_EXTENSION[extKey] || 'audio/webm';
|
||||||
|
|
||||||
|
return proxyR2MediaObject({
|
||||||
|
request,
|
||||||
|
key,
|
||||||
|
fallbackContentType: contentType,
|
||||||
|
cacheControl: 'private, no-store',
|
||||||
|
extraHeaders: {
|
||||||
|
'Content-Disposition': contentDisposition,
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
},
|
||||||
|
internalErrorMessage: 'Failed to retrieve audio',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const sourceParam = request.nextUrl.searchParams.get('source');
|
const sourceParam = request.nextUrl.searchParams.get('source');
|
||||||
const rawQuality = request.nextUrl.searchParams.get('quality');
|
const rawQuality = request.nextUrl.searchParams.get('quality');
|
||||||
const isPrepareOnly = request.nextUrl.searchParams.get('prepare') === '1';
|
const isPrepareOnly = request.nextUrl.searchParams.get('prepare') === '1';
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let shouldDeleteImageObject = false;
|
let shouldDeleteImageObject = false;
|
||||||
|
let shouldDeleteAudioObject = false;
|
||||||
await db.$transaction(async (tx) => {
|
await db.$transaction(async (tx) => {
|
||||||
await tx.videoAsset.delete({ where: { id: asset.id } });
|
await tx.videoAsset.delete({ where: { id: asset.id } });
|
||||||
|
|
||||||
@@ -52,12 +53,23 @@ export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
|||||||
]);
|
]);
|
||||||
shouldDeleteImageObject = assetReferenceCount === 0 && commentReferenceCount === 0;
|
shouldDeleteImageObject = assetReferenceCount === 0 && commentReferenceCount === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (asset.provider === VideoAssetProvider.R2_AUDIO) {
|
||||||
|
const [assetReferenceCount, commentReferenceCount] = await Promise.all([
|
||||||
|
tx.videoAsset.count({ where: { sourceUrl: asset.sourceUrl } }),
|
||||||
|
tx.comment.count({ where: { voiceUrl: asset.sourceUrl } }),
|
||||||
|
]);
|
||||||
|
shouldDeleteAudioObject = assetReferenceCount === 0 && commentReferenceCount === 0;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let r2CleanupResult: Awaited<ReturnType<typeof deleteMediaFilesBestEffort>> | undefined;
|
let r2CleanupResult: Awaited<ReturnType<typeof deleteMediaFilesBestEffort>> | undefined;
|
||||||
if (asset.provider === VideoAssetProvider.R2_IMAGE && shouldDeleteImageObject) {
|
if (asset.provider === VideoAssetProvider.R2_IMAGE && shouldDeleteImageObject) {
|
||||||
r2CleanupResult = await deleteMediaFilesBestEffort([asset.sourceUrl]);
|
r2CleanupResult = await deleteMediaFilesBestEffort([asset.sourceUrl]);
|
||||||
}
|
}
|
||||||
|
if (asset.provider === VideoAssetProvider.R2_AUDIO && shouldDeleteAudioObject) {
|
||||||
|
r2CleanupResult = await deleteMediaFilesBestEffort([asset.sourceUrl]);
|
||||||
|
}
|
||||||
|
|
||||||
let bunnyCleanupResult: Awaited<ReturnType<typeof cleanupBunnyStreamVideosBestEffort>> | undefined;
|
let bunnyCleanupResult: Awaited<ReturnType<typeof cleanupBunnyStreamVideosBestEffort>> | undefined;
|
||||||
if (asset.provider === VideoAssetProvider.BUNNY && asset.providerVideoId) {
|
if (asset.provider === VideoAssetProvider.BUNNY && asset.providerVideoId) {
|
||||||
|
|||||||
@@ -15,9 +15,12 @@ import { resolveServerBunnyCdnHostname } from '@/lib/bunny-cdn';
|
|||||||
import {
|
import {
|
||||||
SAFE_BUNNY_VIDEO_ID,
|
SAFE_BUNNY_VIDEO_ID,
|
||||||
SAFE_IMAGE_PROXY_PATH,
|
SAFE_IMAGE_PROXY_PATH,
|
||||||
|
SAFE_AUDIO_PROXY_PATH,
|
||||||
canDeleteAssetForViewer,
|
canDeleteAssetForViewer,
|
||||||
extractImageFileNameFromProxyUrl,
|
extractImageFileNameFromProxyUrl,
|
||||||
extractImageKeyFromProxyUrl,
|
extractImageKeyFromProxyUrl,
|
||||||
|
extractAudioKeyFromProxyUrl,
|
||||||
|
extractAudioFileNameFromProxyUrl,
|
||||||
getVideoAssetAccessContext,
|
getVideoAssetAccessContext,
|
||||||
sanitizeAssetDisplayName,
|
sanitizeAssetDisplayName,
|
||||||
} from '@/lib/video-assets';
|
} from '@/lib/video-assets';
|
||||||
@@ -32,7 +35,7 @@ const YOUTUBE_TITLE_CACHE_TTL_MS = 5 * 60 * 1000;
|
|||||||
type AssetWithViewerFields = {
|
type AssetWithViewerFields = {
|
||||||
id: string;
|
id: string;
|
||||||
videoId: string;
|
videoId: string;
|
||||||
kind: 'IMAGE' | 'VIDEO';
|
kind: 'IMAGE' | 'VIDEO' | 'AUDIO';
|
||||||
provider: VideoAssetProvider;
|
provider: VideoAssetProvider;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
sourceUrl: string;
|
sourceUrl: string;
|
||||||
@@ -159,6 +162,22 @@ async function isFreshImageAttachment(url: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function isFreshAudioAttachment(url: string): Promise<boolean> {
|
||||||
|
const key = extractAudioKeyFromProxyUrl(url);
|
||||||
|
if (!key) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const head = await r2Client.send(new HeadObjectCommand({
|
||||||
|
Bucket: R2_BUCKET_NAME,
|
||||||
|
Key: key,
|
||||||
|
}));
|
||||||
|
if (!head.LastModified) return false;
|
||||||
|
return Date.now() - head.LastModified.getTime() <= UNATTACHED_UPLOAD_TTL_MS;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/videos/[videoId]/assets
|
// GET /api/videos/[videoId]/assets
|
||||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||||
try {
|
try {
|
||||||
@@ -225,7 +244,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
const response = successResponse({
|
const response = successResponse({
|
||||||
assets: pagedAssets.map((asset) => shapeAssetForViewer(
|
assets: pagedAssets.map((asset) => shapeAssetForViewer(
|
||||||
asset,
|
asset,
|
||||||
context.canDownloadAssets,
|
// R2_AUDIO proxy URLs have no auth gate — expose them to any viewer so guests can preview audio
|
||||||
|
context.canDownloadAssets || (asset.provider === VideoAssetProvider.R2_AUDIO && context.hasViewAccess),
|
||||||
includeDeleteMetadata ? canDeleteAssetForViewer(asset, context) : false
|
includeDeleteMetadata ? canDeleteAssetForViewer(asset, context) : false
|
||||||
)),
|
)),
|
||||||
pagination: {
|
pagination: {
|
||||||
@@ -259,7 +279,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
const body = await request.json().catch(() => null);
|
const body = await request.json().catch(() => null);
|
||||||
const provider = typeof body?.provider === 'string' ? body.provider.trim().toUpperCase() : '';
|
const provider = typeof body?.provider === 'string' ? body.provider.trim().toUpperCase() : '';
|
||||||
|
|
||||||
if (provider !== VideoAssetProvider.R2_IMAGE && provider !== VideoAssetProvider.YOUTUBE && provider !== VideoAssetProvider.BUNNY) {
|
if (
|
||||||
|
provider !== VideoAssetProvider.R2_IMAGE &&
|
||||||
|
provider !== VideoAssetProvider.YOUTUBE &&
|
||||||
|
provider !== VideoAssetProvider.BUNNY &&
|
||||||
|
provider !== VideoAssetProvider.R2_AUDIO
|
||||||
|
) {
|
||||||
return apiErrors.badRequest('Invalid provider');
|
return apiErrors.badRequest('Invalid provider');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,7 +296,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
let sourceUrl = '';
|
let sourceUrl = '';
|
||||||
let providerVideoId: string | null = null;
|
let providerVideoId: string | null = null;
|
||||||
let thumbnailUrl: string | null = null;
|
let thumbnailUrl: string | null = null;
|
||||||
let kind: 'IMAGE' | 'VIDEO' = 'IMAGE';
|
let kind: 'IMAGE' | 'VIDEO' | 'AUDIO' = 'IMAGE';
|
||||||
|
|
||||||
if (provider === VideoAssetProvider.R2_IMAGE) {
|
if (provider === VideoAssetProvider.R2_IMAGE) {
|
||||||
sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : '';
|
sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : '';
|
||||||
@@ -288,6 +313,20 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
kind = 'IMAGE';
|
kind = 'IMAGE';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (provider === VideoAssetProvider.R2_AUDIO) {
|
||||||
|
sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : '';
|
||||||
|
if (!SAFE_AUDIO_PROXY_PATH.test(sourceUrl)) {
|
||||||
|
return apiErrors.badRequest('Audio URL must reference an uploaded audio file');
|
||||||
|
}
|
||||||
|
if (!(await isFreshAudioAttachment(sourceUrl))) {
|
||||||
|
return apiErrors.badRequest('Audio upload expired. Please upload again.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = extractAudioFileNameFromProxyUrl(sourceUrl);
|
||||||
|
displayName = sanitizeAssetDisplayName(requestedDisplayName, fileName || 'Voice Recording');
|
||||||
|
kind = 'AUDIO';
|
||||||
|
}
|
||||||
|
|
||||||
if (provider === VideoAssetProvider.YOUTUBE) {
|
if (provider === VideoAssetProvider.YOUTUBE) {
|
||||||
sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : '';
|
sourceUrl = typeof body?.sourceUrl === 'string' ? body.sourceUrl.trim() : '';
|
||||||
const parsedSource = parseVideoUrl(sourceUrl);
|
const parsedSource = parseVideoUrl(sourceUrl);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { memo, type ReactNode } from 'react';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import {
|
import {
|
||||||
@@ -103,15 +103,15 @@ export const AssetListSection = memo(function AssetListSection({
|
|||||||
size="icon"
|
size="icon"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
title={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' : 'View image'}
|
aria-label={asset.kind === 'VIDEO' ? 'Play video' : asset.kind === 'AUDIO' ? 'Play recording' : 'View image'}
|
||||||
onClick={() => onViewAsset(asset)}
|
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>
|
</Button>
|
||||||
|
|
||||||
{canDownloadAssets && asset.provider !== 'YOUTUBE' && (
|
{canDownloadAssets && asset.provider !== 'YOUTUBE' && (
|
||||||
asset.provider === 'BUNNY' ? (
|
asset.provider === 'BUNNY' && asset.kind !== 'AUDIO' ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
'use client';
|
'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 * as tus from 'tus-js-client';
|
||||||
import { toast } from 'sonner';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Input } from '@/components/ui/input';
|
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 { AssetListSection } from '@/components/video-page/asset-list-section';
|
||||||
import type { VideoAsset } from '@/components/video-page/types';
|
import type { VideoAsset } from '@/components/video-page/types';
|
||||||
import { extractPastedImageFile, validateImageFile } from '@/components/video-page/image-upload-utils';
|
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 { 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 {
|
interface AssetsPaneProps {
|
||||||
videoId: string;
|
videoId: string;
|
||||||
@@ -31,9 +38,9 @@ interface AssetsPaneProps {
|
|||||||
activeDownloadAssetId: string | null;
|
activeDownloadAssetId: string | null;
|
||||||
canUploadAssets: boolean;
|
canUploadAssets: boolean;
|
||||||
canDownloadAssets: boolean;
|
canDownloadAssets: boolean;
|
||||||
getGuestUploadToken: (intent: 'image') => Promise<string | null>;
|
getGuestUploadToken: (intent: 'image' | 'audio') => Promise<string | null>;
|
||||||
createAsset: (payload: {
|
createAsset: (payload: {
|
||||||
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY';
|
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY' | 'R2_AUDIO';
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
sourceUrl: string;
|
sourceUrl: string;
|
||||||
providerVideoId?: string;
|
providerVideoId?: string;
|
||||||
@@ -68,7 +75,7 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
highlightedAssetId,
|
highlightedAssetId,
|
||||||
onHighlightedAssetHandled,
|
onHighlightedAssetHandled,
|
||||||
}: AssetsPaneProps) {
|
}: AssetsPaneProps) {
|
||||||
const [uploadTab, setUploadTab] = useState<'image' | 'youtube' | 'bunny'>('image');
|
const [uploadTab, setUploadTab] = useState<'image' | 'youtube' | 'bunny' | 'voice'>('image');
|
||||||
const [imageTitle, setImageTitle] = useState('');
|
const [imageTitle, setImageTitle] = useState('');
|
||||||
const [pendingImageFile, setPendingImageFile] = useState<File | null>(null);
|
const [pendingImageFile, setPendingImageFile] = useState<File | null>(null);
|
||||||
const [youtubeUrl, setYoutubeUrl] = useState('');
|
const [youtubeUrl, setYoutubeUrl] = useState('');
|
||||||
@@ -91,6 +98,25 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||||
const bunnyInputRef = 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(() => {
|
const sortedAssets = useMemo(() => {
|
||||||
return [...assets].sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt));
|
return [...assets].sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt));
|
||||||
}, [assets]);
|
}, [assets]);
|
||||||
@@ -227,7 +253,7 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
setBunnyProcessingByAssetId((prev) => (prev[selectedAsset.id] ? prev : { ...prev, [selectedAsset.id]: true }));
|
setBunnyProcessingByAssetId((prev) => (prev[selectedAsset.id] ? prev : { ...prev, [selectedAsset.id]: true }));
|
||||||
}, [bunnyReadyByAssetId, selectedAsset]);
|
}, [bunnyReadyByAssetId, selectedAsset]);
|
||||||
|
|
||||||
const handleImageUpload = async (file: File) => {
|
const handleImageUpload = useCallback(async (file: File) => {
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
const imageError = validateImageFile(file);
|
const imageError = validateImageFile(file);
|
||||||
@@ -266,7 +292,7 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
console.error('Failed to upload image asset:', error);
|
console.error('Failed to upload image asset:', error);
|
||||||
toast.error('Failed to upload image');
|
toast.error('Failed to upload image');
|
||||||
}
|
}
|
||||||
};
|
}, [videoId, getGuestUploadToken, createAsset, imageTitle]);
|
||||||
|
|
||||||
const handleImageFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleImageFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
@@ -307,9 +333,7 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBunnyUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleBunnyFileUpload = useCallback(async (file: File) => {
|
||||||
const file = event.target.files?.[0];
|
|
||||||
if (!file) return;
|
|
||||||
if (!file.type.startsWith('video/')) {
|
if (!file.type.startsWith('video/')) {
|
||||||
toast.error('Please select a video file');
|
toast.error('Please select a video file');
|
||||||
return;
|
return;
|
||||||
@@ -403,6 +427,12 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
setIsUploadingBunny(false);
|
setIsUploadingBunny(false);
|
||||||
setBunnyProgress(0);
|
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) => {
|
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) => {
|
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') {
|
if (asset.kind === 'IMAGE') {
|
||||||
const imageSrc = asset.thumbnailUrl || asset.sourceUrl;
|
const imageSrc = asset.thumbnailUrl || asset.sourceUrl;
|
||||||
return (
|
return (
|
||||||
@@ -501,6 +685,10 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
setPreviewImageTitle(asset.displayName);
|
setPreviewImageTitle(asset.displayName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (asset.kind === 'AUDIO') {
|
||||||
|
setSelectedAsset(asset);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (asset.provider === 'BUNNY' && !bunnyReadyByAssetId[asset.id]) {
|
if (asset.provider === 'BUNNY' && !bunnyReadyByAssetId[asset.id]) {
|
||||||
setBunnyProcessingByAssetId((prev) => (prev[asset.id] ? prev : { ...prev, [asset.id]: true }));
|
setBunnyProcessingByAssetId((prev) => (prev[asset.id] ? prev : { ...prev, [asset.id]: true }));
|
||||||
}
|
}
|
||||||
@@ -513,7 +701,14 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
: false;
|
: false;
|
||||||
|
|
||||||
return (
|
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 justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium">Assets</span>
|
<span className="font-medium">Assets</span>
|
||||||
@@ -522,12 +717,19 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canUploadAssets ? (
|
{canUploadAssets ? (
|
||||||
<div className="rounded-lg border p-3 space-y-3">
|
<div className={cn('rounded-lg border p-3 space-y-3 relative transition-colors', isDragOver && 'border-primary bg-primary/5')}>
|
||||||
<Tabs value={uploadTab} onValueChange={(value) => setUploadTab(value as 'image' | 'youtube' | 'bunny')}>
|
{isDragOver && (
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
<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="image">Image</TabsTrigger>
|
||||||
<TabsTrigger value="youtube">YouTube</TabsTrigger>
|
<TabsTrigger value="youtube">YouTube</TabsTrigger>
|
||||||
<TabsTrigger value="bunny">Video</TabsTrigger>
|
<TabsTrigger value="bunny">Video</TabsTrigger>
|
||||||
|
<TabsTrigger value="voice">Voice</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
@@ -636,6 +838,111 @@ export const AssetsPane = memo(function AssetsPane({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg border p-3 text-xs text-muted-foreground">
|
<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)}>
|
<Dialog open={selectedAsset?.kind === 'VIDEO'} onOpenChange={(open) => !open && setSelectedAsset(null)}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
showCloseButton={false}
|
showCloseButton={false}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
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';
|
import type { VideoAsset } from '@/components/video-page/types';
|
||||||
|
|
||||||
const URL_REGEX = /(https?:\/\/[^\s]+)/g;
|
const URL_REGEX = /(https?:\/\/[^\s]+)/g;
|
||||||
@@ -50,7 +50,7 @@ export function CommentRichText({ text, onAssetMentionClick, assets = [] }: Comm
|
|||||||
const assetId = match[2] || '';
|
const assetId = match[2] || '';
|
||||||
const matchedAsset = assets.find((asset) => asset.id === assetId);
|
const matchedAsset = assets.find((asset) => asset.id === assetId);
|
||||||
const label = matchedAsset?.displayName || fallbackLabel;
|
const label = matchedAsset?.displayName || fallbackLabel;
|
||||||
const isVideoAsset = matchedAsset?.kind === 'VIDEO';
|
const assetKind = matchedAsset?.kind;
|
||||||
|
|
||||||
nodes.push(
|
nodes.push(
|
||||||
<button
|
<button
|
||||||
@@ -65,12 +65,14 @@ export function CommentRichText({ text, onAssetMentionClick, assets = [] }: Comm
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
isVideoAsset
|
assetKind === 'VIDEO'
|
||||||
? 'inline-flex h-4 w-4 shrink-0 items-center justify-center rounded bg-violet-500/25 text-violet-200'
|
? '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'
|
: '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>
|
||||||
<span className="truncate max-w-[190px] sm:max-w-[240px]">
|
<span className="truncate max-w-[190px] sm:max-w-[240px]">
|
||||||
@{label}
|
@{label}
|
||||||
|
|||||||
@@ -86,6 +86,17 @@ export function useCommentMedia() {
|
|||||||
void audio.play();
|
void audio.play();
|
||||||
}, [playingVoiceId, voicePlaybackRate, startVoiceTracking, stopVoiceTracking]);
|
}, [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(() => {
|
const toggleVoiceSpeed = useCallback(() => {
|
||||||
setVoicePlaybackRate((prev) => {
|
setVoicePlaybackRate((prev) => {
|
||||||
const next = prev === 1 ? 2 : 1;
|
const next = prev === 1 ? 2 : 1;
|
||||||
@@ -112,6 +123,7 @@ export function useCommentMedia() {
|
|||||||
voiceCurrentTime,
|
voiceCurrentTime,
|
||||||
voicePlaybackRate,
|
voicePlaybackRate,
|
||||||
playVoice,
|
playVoice,
|
||||||
|
stopVoice,
|
||||||
toggleVoiceSpeed,
|
toggleVoiceSpeed,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { VideoAsset } from '@/components/video-page/types';
|
|||||||
type BunnyDownloadPreference = 'original' | 'compressed';
|
type BunnyDownloadPreference = 'original' | 'compressed';
|
||||||
|
|
||||||
type CreateAssetPayload = {
|
type CreateAssetPayload = {
|
||||||
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY';
|
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY' | 'R2_AUDIO';
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
sourceUrl: string;
|
sourceUrl: string;
|
||||||
providerVideoId?: string;
|
providerVideoId?: string;
|
||||||
@@ -240,7 +240,7 @@ export function useVideoAssets({
|
|||||||
}
|
}
|
||||||
}, [canDownloadAssets, videoId]);
|
}, [canDownloadAssets, videoId]);
|
||||||
|
|
||||||
const getGuestUploadToken = useCallback(async (intent: 'image') => {
|
const getGuestUploadToken = useCallback(async (intent: 'image' | 'audio') => {
|
||||||
if (isAuthenticated) return null;
|
if (isAuthenticated) return null;
|
||||||
const response = await fetch(`/api/watch/${videoId}/upload-token`, {
|
const response = await fetch(`/api/watch/${videoId}/upload-token`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ export interface CommentTag {
|
|||||||
export interface VideoAsset {
|
export interface VideoAsset {
|
||||||
id: string;
|
id: string;
|
||||||
videoId: string;
|
videoId: string;
|
||||||
kind: 'IMAGE' | 'VIDEO';
|
kind: 'IMAGE' | 'VIDEO' | 'AUDIO';
|
||||||
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY';
|
provider: 'R2_IMAGE' | 'YOUTUBE' | 'BUNNY' | 'R2_AUDIO';
|
||||||
displayName: string;
|
displayName: string;
|
||||||
sourceUrl: string | null;
|
sourceUrl: string | null;
|
||||||
providerVideoId: string | null;
|
providerVideoId: string | null;
|
||||||
|
|||||||
+29
-2
@@ -257,7 +257,7 @@ export async function getCachedUserMediaStorage(): Promise<Record<string, { tota
|
|||||||
const snapshot = await getR2StorageSnapshot();
|
const snapshot = await getR2StorageSnapshot();
|
||||||
const seenKeys = new Set<string>();
|
const seenKeys = new Set<string>();
|
||||||
|
|
||||||
const [mediaComments, mediaAssets] = await Promise.all([
|
const [mediaComments, imageAssets, audioAssets] = await Promise.all([
|
||||||
db.comment.findMany({
|
db.comment.findMany({
|
||||||
where: { OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }] },
|
where: { OR: [{ voiceUrl: { not: null } }, { imageUrl: { not: null } }] },
|
||||||
select: {
|
select: {
|
||||||
@@ -287,6 +287,13 @@ export async function getCachedUserMediaStorage(): Promise<Record<string, { tota
|
|||||||
billedUserId: true,
|
billedUserId: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
db.videoAsset.findMany({
|
||||||
|
where: { provider: 'R2_AUDIO' },
|
||||||
|
select: {
|
||||||
|
sourceUrl: true,
|
||||||
|
billedUserId: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
for (const comment of mediaComments) {
|
for (const comment of mediaComments) {
|
||||||
@@ -324,7 +331,7 @@ export async function getCachedUserMediaStorage(): Promise<Record<string, { tota
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const asset of mediaAssets) {
|
for (const asset of imageAssets) {
|
||||||
const billedUserId = asset.billedUserId;
|
const billedUserId = asset.billedUserId;
|
||||||
if (!billedUserId) continue;
|
if (!billedUserId) continue;
|
||||||
if (!userStorage[billedUserId]) {
|
if (!userStorage[billedUserId]) {
|
||||||
@@ -343,6 +350,26 @@ export async function getCachedUserMediaStorage(): Promise<Record<string, { tota
|
|||||||
userStorage[billedUserId].image += size;
|
userStorage[billedUserId].image += size;
|
||||||
userStorage[billedUserId].total += size;
|
userStorage[billedUserId].total += size;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const asset of audioAssets) {
|
||||||
|
const billedUserId = asset.billedUserId;
|
||||||
|
if (!billedUserId) continue;
|
||||||
|
if (!userStorage[billedUserId]) {
|
||||||
|
userStorage[billedUserId] = { total: 0, voice: 0, image: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyParts = asset.sourceUrl.split('/');
|
||||||
|
const filename = keyParts[keyParts.length - 1];
|
||||||
|
if (!filename) continue;
|
||||||
|
const r2Key = `voice/${filename}`;
|
||||||
|
const dedupeKey = `${billedUserId}:${r2Key}`;
|
||||||
|
if (seenKeys.has(dedupeKey)) continue;
|
||||||
|
seenKeys.add(dedupeKey);
|
||||||
|
|
||||||
|
const size = snapshot.fileSizes.get(r2Key) || 0;
|
||||||
|
userStorage[billedUserId].voice += size;
|
||||||
|
userStorage[billedUserId].total += size;
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to parse user storage:', err);
|
console.error('Failed to parse user storage:', err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const IMAGE_PROXY_PREFIX = '/api/upload/image/';
|
|||||||
const AUDIO_PROXY_PREFIX = '/api/upload/audio/';
|
const AUDIO_PROXY_PREFIX = '/api/upload/audio/';
|
||||||
|
|
||||||
export const SAFE_IMAGE_PROXY_PATH = /^\/api\/upload\/image\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
|
export const SAFE_IMAGE_PROXY_PATH = /^\/api\/upload\/image\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
|
||||||
|
export const SAFE_AUDIO_PROXY_PATH = /^\/api\/upload\/audio\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
|
||||||
export const SAFE_BUNNY_VIDEO_ID = /^[A-Za-z0-9_-]{8,128}$/;
|
export const SAFE_BUNNY_VIDEO_ID = /^[A-Za-z0-9_-]{8,128}$/;
|
||||||
|
|
||||||
export type VideoAssetAccessContext = {
|
export type VideoAssetAccessContext = {
|
||||||
@@ -61,6 +62,19 @@ export function extractImageFileNameFromProxyUrl(url: string): string | null {
|
|||||||
return filename || null;
|
return filename || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function extractAudioKeyFromProxyUrl(url: string): string | null {
|
||||||
|
if (!SAFE_AUDIO_PROXY_PATH.test(url)) return null;
|
||||||
|
const filename = url.slice(AUDIO_PROXY_PREFIX.length);
|
||||||
|
if (!filename) return null;
|
||||||
|
return `voice/${filename}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractAudioFileNameFromProxyUrl(url: string): string | null {
|
||||||
|
if (!SAFE_AUDIO_PROXY_PATH.test(url)) return null;
|
||||||
|
const filename = url.slice(AUDIO_PROXY_PREFIX.length);
|
||||||
|
return filename || null;
|
||||||
|
}
|
||||||
|
|
||||||
export function mediaUrlToR2Key(url: string): string | null {
|
export function mediaUrlToR2Key(url: string): string | null {
|
||||||
if (url.includes(IMAGE_PROXY_PREFIX)) {
|
if (url.includes(IMAGE_PROXY_PREFIX)) {
|
||||||
const filename = url.slice(url.indexOf(IMAGE_PROXY_PREFIX) + IMAGE_PROXY_PREFIX.length);
|
const filename = url.slice(url.indexOf(IMAGE_PROXY_PREFIX) + IMAGE_PROXY_PREFIX.length);
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "VideoAssetKind" ADD VALUE 'AUDIO';
|
||||||
|
|
||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "VideoAssetProvider" ADD VALUE 'R2_AUDIO';
|
||||||
Reference in New Issue
Block a user