mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
Add a new "test-jazz-stream" task to flow.toml for testing live stream recording flow, including setup instructions and verification steps.
Update CommentBox component to handle image uploads with validation, preview, and progress tracking, and to manage comments with Jazz container initialization.
This commit is contained in:
@@ -1,17 +1,14 @@
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import { Send, LogIn } from "lucide-react"
|
||||
import { Send, LogIn, ImagePlus, X } from "lucide-react"
|
||||
import { authClient } from "@/lib/auth-client"
|
||||
|
||||
type Comment = {
|
||||
id: string
|
||||
user_id: string
|
||||
user_name: string
|
||||
user_email: string
|
||||
content: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
type AuthStep = "idle" | "email" | "otp"
|
||||
import { useAccount, useCoState } from "jazz-tools/react"
|
||||
import { Group, co, FileStream } from "jazz-tools"
|
||||
import {
|
||||
StreamComment,
|
||||
StreamCommentList,
|
||||
StreamCommentsContainer,
|
||||
ViewerAccount,
|
||||
} from "@/lib/jazz/schema"
|
||||
|
||||
interface CommentBoxProps {
|
||||
username: string
|
||||
@@ -19,13 +16,18 @@ interface CommentBoxProps {
|
||||
|
||||
export function CommentBox({ username }: CommentBoxProps) {
|
||||
const { data: session, isPending: sessionLoading } = authClient.useSession()
|
||||
const [comments, setComments] = useState<Comment[]>([])
|
||||
const me = useAccount(ViewerAccount)
|
||||
|
||||
const [containerId, setContainerId] = useState<string | undefined>(undefined)
|
||||
const container = useCoState(StreamCommentsContainer, containerId, { resolve: { comments: true } })
|
||||
const [newComment, setNewComment] = useState("")
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [selectedImage, setSelectedImage] = useState<File | null>(null)
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
|
||||
// Auth state
|
||||
const [authStep, setAuthStep] = useState<AuthStep>("idle")
|
||||
const [authStep, setAuthStep] = useState<"idle" | "email" | "otp">("idle")
|
||||
const [email, setEmail] = useState("")
|
||||
const [otp, setOtp] = useState("")
|
||||
const [authLoading, setAuthLoading] = useState(false)
|
||||
@@ -34,6 +36,7 @@ export function CommentBox({ username }: CommentBoxProps) {
|
||||
const commentsEndRef = useRef<HTMLDivElement>(null)
|
||||
const emailInputRef = useRef<HTMLInputElement>(null)
|
||||
const otpInputRef = useRef<HTMLInputElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Focus inputs when auth step changes
|
||||
useEffect(() => {
|
||||
@@ -44,32 +47,72 @@ export function CommentBox({ username }: CommentBoxProps) {
|
||||
}
|
||||
}, [authStep])
|
||||
|
||||
// Fetch comments
|
||||
// Initialize or load the comments container for this stream
|
||||
useEffect(() => {
|
||||
const fetchComments = async () => {
|
||||
if (!me?.$isLoaded) return
|
||||
|
||||
const initContainer = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/stream-comments?username=${username}`)
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { comments?: Comment[] }
|
||||
setComments(data.comments || [])
|
||||
}
|
||||
const containerUID = { stream: username, origin: "linsa.io", type: "comments" }
|
||||
|
||||
// Create a group writable by everyone
|
||||
const group = Group.create({ owner: me })
|
||||
group.addMember("everyone", "writer")
|
||||
|
||||
// Upsert the container
|
||||
const result = await StreamCommentsContainer.upsertUnique({
|
||||
value: { comments: StreamCommentList.create([], { owner: group }) },
|
||||
unique: containerUID,
|
||||
owner: group,
|
||||
})
|
||||
|
||||
setContainerId(result.$jazz.id)
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch comments:", err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
console.error("Failed to init comments container:", err)
|
||||
}
|
||||
}
|
||||
|
||||
fetchComments()
|
||||
const interval = setInterval(fetchComments, 5000) // Poll every 5 seconds
|
||||
initContainer()
|
||||
}, [me?.$isLoaded, username])
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [username])
|
||||
// Get comments from the container (only when loaded)
|
||||
const comments = container?.$isLoaded ? container.comments : undefined
|
||||
|
||||
// Scroll to bottom when new comments arrive
|
||||
useEffect(() => {
|
||||
commentsEndRef.current?.scrollIntoView({ behavior: "smooth" })
|
||||
}, [comments])
|
||||
}, [comments?.length])
|
||||
|
||||
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith("image/")) {
|
||||
alert("Please select an image file")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate file size (max 10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
alert("Image must be less than 10MB")
|
||||
return
|
||||
}
|
||||
|
||||
setSelectedImage(file)
|
||||
setImagePreview(URL.createObjectURL(file))
|
||||
}
|
||||
|
||||
const clearSelectedImage = () => {
|
||||
setSelectedImage(null)
|
||||
if (imagePreview) {
|
||||
URL.revokeObjectURL(imagePreview)
|
||||
setImagePreview(null)
|
||||
}
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ""
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendOTP = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -112,7 +155,6 @@ export function CommentBox({ username }: CommentBoxProps) {
|
||||
if (result.error) {
|
||||
setAuthError(result.error.message || "Invalid code")
|
||||
} else {
|
||||
// Success - close auth form
|
||||
setAuthStep("idle")
|
||||
setEmail("")
|
||||
setOtp("")
|
||||
@@ -126,24 +168,47 @@ export function CommentBox({ username }: CommentBoxProps) {
|
||||
|
||||
const handleSubmitComment = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!newComment.trim() || !session?.user) return
|
||||
const commentsList = container?.$isLoaded ? container.comments : undefined
|
||||
if ((!newComment.trim() && !selectedImage) || !session?.user || !me?.$isLoaded || !commentsList?.$isLoaded) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const res = await fetch("/api/stream-comments", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
content: newComment.trim(),
|
||||
}),
|
||||
})
|
||||
setUploadProgress(0)
|
||||
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { comment: Comment }
|
||||
setComments((prev) => [...prev, data.comment])
|
||||
setNewComment("")
|
||||
try {
|
||||
// Create a group for the comment
|
||||
const group = Group.create({ owner: me })
|
||||
group.addMember("everyone", "reader")
|
||||
|
||||
// Upload image if selected
|
||||
let imageStream = undefined
|
||||
if (selectedImage) {
|
||||
imageStream = await co.fileStream().createFromBlob(selectedImage, {
|
||||
owner: group,
|
||||
onProgress: (progress) => {
|
||||
setUploadProgress(Math.round(progress * 100))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Create the comment
|
||||
const comment = StreamComment.create(
|
||||
{
|
||||
content: newComment.trim(),
|
||||
userName: session.user.name || session.user.email?.split("@")[0] || "Anonymous",
|
||||
userId: session.user.id || null,
|
||||
image: imageStream,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{ owner: group }
|
||||
)
|
||||
|
||||
// Add to list
|
||||
;(commentsList as unknown as { push: (item: typeof comment) => void }).push(comment)
|
||||
|
||||
// Clear form
|
||||
setNewComment("")
|
||||
clearSelectedImage()
|
||||
setUploadProgress(0)
|
||||
} catch (err) {
|
||||
console.error("Failed to post comment:", err)
|
||||
} finally {
|
||||
@@ -151,8 +216,8 @@ export function CommentBox({ username }: CommentBoxProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (dateStr: string) => {
|
||||
const date = new Date(dateStr)
|
||||
const formatTime = (timestamp: number) => {
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
||||
}
|
||||
|
||||
@@ -167,35 +232,42 @@ export function CommentBox({ username }: CommentBoxProps) {
|
||||
|
||||
{/* Comments list */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
|
||||
{isLoading ? (
|
||||
{!comments ? (
|
||||
<div className="text-center text-white/40 text-sm py-4">Loading...</div>
|
||||
) : comments.length === 0 ? (
|
||||
<div className="text-center text-white/40 text-sm py-4">
|
||||
No messages yet. Be the first to say hi!
|
||||
</div>
|
||||
) : (
|
||||
comments.map((comment) => (
|
||||
<div key={comment.id} className="group">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-6 h-6 rounded-full bg-white/10 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-xs font-medium text-white/70">
|
||||
{comment.user_name?.charAt(0).toUpperCase() || "?"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xs font-medium text-white/60 truncate">
|
||||
{comment.user_name || "Anonymous"}
|
||||
</span>
|
||||
<span className="text-[10px] text-white/30">
|
||||
{formatTime(comment.created_at)}
|
||||
comments.map((comment, index: number) => {
|
||||
if (!comment?.$isLoaded) return null
|
||||
|
||||
return (
|
||||
<div key={comment.$jazz.id || index} className="group">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-6 h-6 rounded-full bg-white/10 flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-xs font-medium text-white/70">
|
||||
{comment.userName?.charAt(0).toUpperCase() || "?"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-white/90 break-words">{comment.content}</p>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xs font-medium text-white/60 truncate">
|
||||
{comment.userName || "Anonymous"}
|
||||
</span>
|
||||
<span className="text-[10px] text-white/30">
|
||||
{formatTime(comment.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
{comment.content && (
|
||||
<p className="text-sm text-white/90 break-words">{comment.content}</p>
|
||||
)}
|
||||
{comment.image?.$isLoaded && <CommentImage image={comment.image} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)
|
||||
})
|
||||
)}
|
||||
<div ref={commentsEndRef} />
|
||||
</div>
|
||||
@@ -205,23 +277,68 @@ export function CommentBox({ username }: CommentBoxProps) {
|
||||
{sessionLoading ? (
|
||||
<div className="text-center text-white/40 text-sm py-2">Loading...</div>
|
||||
) : isAuthenticated ? (
|
||||
<form onSubmit={handleSubmitComment} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
placeholder="Send a message..."
|
||||
className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder:text-white/30 focus:outline-none focus:border-white/20"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newComment.trim() || isSubmitting}
|
||||
className="px-3 py-2 bg-white text-black rounded-lg hover:bg-white/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
</form>
|
||||
<div className="space-y-2">
|
||||
{/* Image preview */}
|
||||
{imagePreview && (
|
||||
<div className="relative inline-block">
|
||||
<img
|
||||
src={imagePreview}
|
||||
alt="Preview"
|
||||
className="max-h-24 rounded-lg border border-white/20"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearSelectedImage}
|
||||
className="absolute -top-2 -right-2 p-1 bg-black/80 rounded-full text-white/70 hover:text-white"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload progress */}
|
||||
{uploadProgress > 0 && uploadProgress < 100 && (
|
||||
<div className="w-full bg-white/10 rounded-full h-1">
|
||||
<div
|
||||
className="bg-teal-500 h-1 rounded-full transition-all"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmitComment} className="flex gap-2">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleImageSelect}
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="px-2 py-2 text-white/50 hover:text-white/80 transition-colors"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<ImagePlus size={18} />
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
placeholder="Send a message..."
|
||||
className="flex-1 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder:text-white/30 focus:outline-none focus:border-white/20"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={(!newComment.trim() && !selectedImage) || isSubmitting}
|
||||
className="px-3 py-2 bg-white text-black rounded-lg hover:bg-white/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
) : authStep === "idle" ? (
|
||||
<button
|
||||
onClick={() => setAuthStep("email")}
|
||||
@@ -241,9 +358,7 @@ export function CommentBox({ username }: CommentBoxProps) {
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder:text-white/30 focus:outline-none focus:border-white/20"
|
||||
/>
|
||||
{authError && (
|
||||
<p className="text-xs text-red-400">{authError}</p>
|
||||
)}
|
||||
{authError && <p className="text-xs text-red-400">{authError}</p>}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -266,9 +381,7 @@ export function CommentBox({ username }: CommentBoxProps) {
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleVerifyOTP} className="space-y-2">
|
||||
<p className="text-xs text-white/60 text-center">
|
||||
Code sent to {email}
|
||||
</p>
|
||||
<p className="text-xs text-white/60 text-center">Code sent to {email}</p>
|
||||
<input
|
||||
ref={otpInputRef}
|
||||
type="text"
|
||||
@@ -281,9 +394,7 @@ export function CommentBox({ username }: CommentBoxProps) {
|
||||
onChange={(e) => setOtp(e.target.value.replace(/\D/g, ""))}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-center text-lg font-mono tracking-widest text-white placeholder:text-white/30 focus:outline-none focus:border-white/20"
|
||||
/>
|
||||
{authError && (
|
||||
<p className="text-xs text-red-400">{authError}</p>
|
||||
)}
|
||||
{authError && <p className="text-xs text-red-400">{authError}</p>}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -310,3 +421,40 @@ export function CommentBox({ username }: CommentBoxProps) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Component to display an image from a FileStream
|
||||
function CommentImage({ image }: { image: FileStream }) {
|
||||
const [url, setUrl] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!image) return
|
||||
|
||||
try {
|
||||
const blob = image.toBlob()
|
||||
if (blob) {
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
setUrl(objectUrl)
|
||||
return () => URL.revokeObjectURL(objectUrl)
|
||||
}
|
||||
} catch {
|
||||
// Image still loading
|
||||
}
|
||||
}, [image])
|
||||
|
||||
if (!url) {
|
||||
return (
|
||||
<div className="mt-2 w-32 h-24 bg-white/5 rounded-lg animate-pulse flex items-center justify-center">
|
||||
<span className="text-xs text-white/30">Loading...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={url}
|
||||
alt="Attached"
|
||||
className="mt-2 max-w-full max-h-48 rounded-lg border border-white/10 cursor-pointer hover:border-white/30 transition-colors"
|
||||
onClick={() => window.open(url, "_blank")}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
274
packages/web/src/components/StreamTimeline.tsx
Normal file
274
packages/web/src/components/StreamTimeline.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react"
|
||||
import { Play, Pause, Download, Maximize2 } from "lucide-react"
|
||||
import type { StreamRecording } from "@/lib/jazz/schema"
|
||||
|
||||
interface StreamTimelineProps {
|
||||
recording: StreamRecording
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Timeline visualization for live stream recordings
|
||||
* Shows horizontal timeline with real-time progress as chunks arrive
|
||||
* Supports horizontal scrolling to navigate through the stream
|
||||
*/
|
||||
export function StreamTimeline({
|
||||
recording,
|
||||
width = 800,
|
||||
height = 120,
|
||||
}: StreamTimelineProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [scrollX, setScrollX] = useState(0)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentTime, setCurrentTime] = useState(0)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [videoUrl, setVideoUrl] = useState<string | null>(null)
|
||||
|
||||
// Convert Jazz FileStream to blob URL for video player
|
||||
useEffect(() => {
|
||||
if (recording.videoFile?.$isLoaded) {
|
||||
try {
|
||||
const blob = recording.videoFile.toBlob()
|
||||
if (blob) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
setVideoUrl(url)
|
||||
return () => URL.revokeObjectURL(url)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[StreamTimeline] Failed to create video URL:", err)
|
||||
}
|
||||
}
|
||||
}, [recording.videoFile])
|
||||
|
||||
// Get stream metadata
|
||||
const startedAt = recording.startedAt
|
||||
const isLive = recording.isLive
|
||||
const duration = recording.durationMs || 0
|
||||
const ended = recording.endedAt !== null
|
||||
|
||||
// Calculate timeline metrics
|
||||
const pixelsPerMs = 0.05 // 1 second = 50 pixels
|
||||
const totalWidth = Math.max(width, duration * pixelsPerMs)
|
||||
const visibleDuration = width / pixelsPerMs
|
||||
|
||||
// Draw timeline
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
|
||||
// Set canvas size for retina displays
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
canvas.width = width * dpr
|
||||
canvas.height = height * dpr
|
||||
ctx.scale(dpr, dpr)
|
||||
|
||||
// Clear canvas
|
||||
ctx.fillStyle = "#0a0e1a"
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
|
||||
// Draw time markers
|
||||
const startMs = scrollX / pixelsPerMs
|
||||
const endMs = (scrollX + width) / pixelsPerMs
|
||||
|
||||
ctx.strokeStyle = "#1e293b"
|
||||
ctx.lineWidth = 1
|
||||
ctx.font = "10px monospace"
|
||||
ctx.fillStyle = "#64748b"
|
||||
|
||||
// Draw vertical lines every 10 seconds
|
||||
const intervalMs = 10000
|
||||
const firstMarker = Math.floor(startMs / intervalMs) * intervalMs
|
||||
for (let ms = firstMarker; ms <= endMs; ms += intervalMs) {
|
||||
const x = ms * pixelsPerMs - scrollX
|
||||
if (x >= 0 && x <= width) {
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, 0)
|
||||
ctx.lineTo(x, height)
|
||||
ctx.stroke()
|
||||
|
||||
// Draw time label
|
||||
const seconds = Math.floor(ms / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
const label = `${minutes}:${secs.toString().padStart(2, "0")}`
|
||||
ctx.fillText(label, x + 4, 12)
|
||||
}
|
||||
}
|
||||
|
||||
// Draw stream progress bar
|
||||
const progressWidth = duration * pixelsPerMs - scrollX
|
||||
if (progressWidth > 0) {
|
||||
ctx.fillStyle = isLive ? "#22c55e" : "#3b82f6"
|
||||
ctx.fillRect(0, height - 20, Math.min(progressWidth, width), 8)
|
||||
}
|
||||
|
||||
// Draw current time indicator (playhead)
|
||||
if (isPlaying || currentTime > 0) {
|
||||
const playheadX = currentTime * pixelsPerMs - scrollX
|
||||
if (playheadX >= 0 && playheadX <= width) {
|
||||
ctx.strokeStyle = "#f59e0b"
|
||||
ctx.lineWidth = 2
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(playheadX, 0)
|
||||
ctx.lineTo(playheadX, height)
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
// Draw live indicator if recording is live
|
||||
if (isLive) {
|
||||
ctx.fillStyle = "#ef4444"
|
||||
ctx.beginPath()
|
||||
ctx.arc(width - 20, 20, 6, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
|
||||
ctx.fillStyle = "#ffffff"
|
||||
ctx.font = "11px monospace"
|
||||
ctx.fillText("LIVE", width - 60, 24)
|
||||
}
|
||||
}, [scrollX, width, height, duration, isLive, isPlaying, currentTime, pixelsPerMs])
|
||||
|
||||
// Handle horizontal scrolling
|
||||
const handleWheel = useCallback(
|
||||
(e: WheelEvent) => {
|
||||
e.preventDefault()
|
||||
const delta = e.deltaX || e.deltaY
|
||||
setScrollX((prev) => {
|
||||
const next = prev + delta
|
||||
return Math.max(0, Math.min(next, Math.max(0, totalWidth - width)))
|
||||
})
|
||||
},
|
||||
[totalWidth, width]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
container.addEventListener("wheel", handleWheel, { passive: false })
|
||||
return () => container.removeEventListener("wheel", handleWheel)
|
||||
}, [handleWheel])
|
||||
|
||||
// Handle click to seek
|
||||
const handleCanvasClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const timeMs = (x + scrollX) / pixelsPerMs
|
||||
|
||||
setCurrentTime(Math.min(timeMs, duration))
|
||||
if (isPlaying) {
|
||||
setIsPlaying(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-scroll to follow live stream
|
||||
useEffect(() => {
|
||||
if (isLive && !isDragging) {
|
||||
const targetScroll = Math.max(0, duration * pixelsPerMs - width)
|
||||
setScrollX(targetScroll)
|
||||
}
|
||||
}, [isLive, duration, pixelsPerMs, width, isDragging])
|
||||
|
||||
const formatTime = (ms: number) => {
|
||||
const seconds = Math.floor(ms / 1000)
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${minutes}:${secs.toString().padStart(2, "0")}`
|
||||
}
|
||||
|
||||
const handleDownload = () => {
|
||||
if (videoUrl) {
|
||||
const a = document.createElement("a")
|
||||
a.href = videoUrl
|
||||
a.download = `${recording.title}.mp4`
|
||||
a.click()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 bg-[#0a0e1a] border border-white/10 rounded-xl p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">{recording.title}</h3>
|
||||
<p className="text-xs text-white/50">
|
||||
{new Date(startedAt).toLocaleString()} · {formatTime(duration)}
|
||||
{isLive && <span className="text-green-400 ml-2">● Recording</span>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{videoUrl && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsPlaying(!isPlaying)}
|
||||
className="p-2 rounded-lg text-white/70 hover:text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
{isPlaying ? <Pause className="w-4 h-4" /> : <Play className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className="p-2 rounded-lg text-white/70 hover:text-white hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timeline Canvas */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative overflow-hidden rounded-lg bg-[#0c0f18] border border-white/5"
|
||||
style={{ width, height }}
|
||||
onMouseDown={() => setIsDragging(true)}
|
||||
onMouseUp={() => setIsDragging(false)}
|
||||
onMouseLeave={() => setIsDragging(false)}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
style={{ width, height }}
|
||||
onClick={handleCanvasClick}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Scroll indicator */}
|
||||
<div className="flex items-center justify-between text-xs text-white/40">
|
||||
<span>Scroll or drag to navigate</span>
|
||||
<span>
|
||||
{formatTime(scrollX / pixelsPerMs)} - {formatTime((scrollX + width) / pixelsPerMs)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
{recording.metadata && (
|
||||
<div className="grid grid-cols-4 gap-2 text-xs">
|
||||
{recording.metadata.width && (
|
||||
<div className="text-white/50">
|
||||
Resolution: {recording.metadata.width}×{recording.metadata.height}
|
||||
</div>
|
||||
)}
|
||||
{recording.metadata.fps && (
|
||||
<div className="text-white/50">FPS: {recording.metadata.fps.toFixed(1)}</div>
|
||||
)}
|
||||
{recording.metadata.bitrate && (
|
||||
<div className="text-white/50">
|
||||
Bitrate: {(recording.metadata.bitrate / 1000).toFixed(0)}kbps
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user