media-console.tsx 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. 'use client';
  2. import { ChangeEvent, FormEvent, useEffect, useRef, useState } from 'react';
  3. import { Badge } from '@/components/ui/badge';
  4. import { Button } from '@/components/ui/button';
  5. import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
  6. import HlsPlayer from './media/[id]/hls-player';
  7. type MediaStatus = {
  8. id: string;
  9. resourceId: string | null;
  10. filename: string;
  11. status: string;
  12. hlsPath: string | null;
  13. hlsUrl: string | null;
  14. errorMessage: string | null;
  15. metadata: { hlsPath?: string; processedAt?: string } | null;
  16. createdAt: string;
  17. updatedAt: string;
  18. };
  19. type UploadResponse = { mediaId: string; filename: string; status: string };
  20. const terminalStates = new Set(['completed', 'failed']);
  21. export default function MediaConsole() {
  22. const [selectedFile, setSelectedFile] = useState<File | null>(null);
  23. const [uploading, setUploading] = useState(false);
  24. const [error, setError] = useState<string | null>(null);
  25. const [mediaStatus, setMediaStatus] = useState<MediaStatus | null>(null);
  26. const [lastUpload, setLastUpload] = useState<UploadResponse | null>(null);
  27. const inputRef = useRef<HTMLInputElement | null>(null);
  28. const mediaId = lastUpload?.mediaId;
  29. useEffect(() => {
  30. if (!mediaId || terminalStates.has(mediaStatus?.status || '')) return;
  31. let cancelled = false;
  32. async function pollStatus() {
  33. try {
  34. const response = await fetch(`/api/media/${mediaId}/status`, { cache: 'no-store' });
  35. const payload = await response.json();
  36. if (!response.ok) throw new Error(payload.error || '状态查询失败');
  37. if (!cancelled) setMediaStatus(payload.media);
  38. } catch (err) {
  39. if (!cancelled) setError(err instanceof Error ? err.message : '状态查询失败');
  40. }
  41. }
  42. pollStatus();
  43. const timer = window.setInterval(pollStatus, 1500);
  44. return () => { cancelled = true; window.clearInterval(timer); };
  45. }, [mediaId, mediaStatus?.status]);
  46. function handleFileChange(event: ChangeEvent<HTMLInputElement>) {
  47. setSelectedFile(event.target.files?.[0] || null);
  48. setError(null);
  49. }
  50. async function handleSubmit(event: FormEvent<HTMLFormElement>) {
  51. event.preventDefault();
  52. if (!selectedFile) { setError('请选择一个视频文件'); return; }
  53. setUploading(true);
  54. setError(null);
  55. setMediaStatus(null);
  56. setLastUpload(null);
  57. const formData = new FormData();
  58. formData.append('file', selectedFile);
  59. try {
  60. const response = await fetch('/api/media/upload', { method: 'POST', body: formData });
  61. const payload = await response.json();
  62. if (!response.ok) throw new Error(payload.error || '上传失败');
  63. setLastUpload(payload.media);
  64. setMediaStatus({
  65. id: payload.media.mediaId,
  66. filename: payload.media.filename,
  67. status: payload.media.status,
  68. hlsPath: null,
  69. hlsUrl: null,
  70. resourceId: null,
  71. errorMessage: null,
  72. metadata: null,
  73. createdAt: new Date().toISOString(),
  74. updatedAt: new Date().toISOString(),
  75. });
  76. } catch (err) {
  77. setError(err instanceof Error ? err.message : '上传失败');
  78. } finally {
  79. setUploading(false);
  80. }
  81. }
  82. const canUpload = Boolean(selectedFile) && !uploading;
  83. return (
  84. <main className="min-h-screen p-8 max-md:p-5">
  85. <section className="mx-auto max-w-6xl">
  86. <div className="mb-6 flex items-end justify-between gap-5 max-md:items-start">
  87. <div>
  88. <p className="mb-2 text-xs uppercase text-[hsl(var(--muted-foreground))]">EKB Media Pipeline</p>
  89. <h1 className="text-4xl font-semibold max-md:text-3xl">媒体处理控制台</h1>
  90. </div>
  91. <Badge>{mediaStatus?.status || 'idle'}</Badge>
  92. </div>
  93. <form className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3 rounded-lg border bg-white p-5 max-md:grid-cols-1" onSubmit={handleSubmit}>
  94. <input ref={inputRef} className="hidden" type="file" accept="video/*" onChange={handleFileChange} />
  95. <Button variant="outline" type="button" onClick={() => inputRef.current?.click()}>选择视频</Button>
  96. <div className="grid min-w-0 gap-1">
  97. <strong className="[overflow-wrap:anywhere]">{selectedFile?.name || '未选择文件'}</strong>
  98. <span className="text-sm text-[hsl(var(--muted-foreground))]">{selectedFile ? `${Math.ceil(selectedFile.size / 1024)} KB` : '支持 MP4 等视频文件'}</span>
  99. </div>
  100. <Button type="submit" disabled={!canUpload}>{uploading ? '上传中' : '上传并转码'}</Button>
  101. </form>
  102. {error ? <div className="mt-4 rounded-md border border-red-300 bg-red-50 p-3 text-sm text-red-800">{error}</div> : null}
  103. <section className="mt-5 grid grid-cols-2 gap-5 max-md:grid-cols-1">
  104. <Card>
  105. <CardHeader><CardTitle>处理状态</CardTitle></CardHeader>
  106. <CardContent>
  107. <dl className="grid gap-3">
  108. {[
  109. ['媒体 ID', mediaStatus?.id || '-'],
  110. ['资源 ID', mediaStatus?.resourceId || '-'],
  111. ['文件名', mediaStatus?.filename || selectedFile?.name || '-'],
  112. ['HLS Key', mediaStatus?.hlsPath || '-'],
  113. ['完成时间', mediaStatus?.metadata?.processedAt || '-'],
  114. ['失败原因', mediaStatus?.errorMessage || '-'],
  115. ].map(([label, value]) => (
  116. <div className="grid grid-cols-[100px_minmax(0,1fr)] gap-3 border-b pb-3 last:border-b-0" key={label}>
  117. <dt className="text-[hsl(var(--muted-foreground))]">{label}</dt>
  118. <dd className="[overflow-wrap:anywhere]">{value}</dd>
  119. </div>
  120. ))}
  121. </dl>
  122. </CardContent>
  123. </Card>
  124. <Card>
  125. <CardHeader><CardTitle>播放预览</CardTitle></CardHeader>
  126. <CardContent className="grid gap-4">
  127. {mediaStatus?.hlsUrl ? (
  128. <>
  129. <HlsPlayer src={mediaStatus.hlsUrl} />
  130. <Button variant="outline" type="button" onClick={() => { window.location.href = '/media'; }}>查看媒体列表</Button>
  131. </>
  132. ) : (
  133. <div className="grid aspect-video w-full place-items-center rounded-md border border-dashed text-[hsl(var(--muted-foreground))]">
  134. {mediaStatus?.status === 'failed' ? '处理失败' : '等待 HLS 输出'}
  135. </div>
  136. )}
  137. </CardContent>
  138. </Card>
  139. </section>
  140. </section>
  141. </main>
  142. );
  143. }