feat: implement more of playback and reply flow
This commit is contained in:
@@ -0,0 +1,84 @@
|
||||
import { Heart } from "lucide-react";
|
||||
import { useCallback } from "react";
|
||||
import type { AckInfo } from "@/api/types";
|
||||
import { apiClient } from "@/api/client";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useAppStore } from "@/stores/app-store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
interface AckButtonProps {
|
||||
particleId: string;
|
||||
acks: AckInfo[];
|
||||
}
|
||||
|
||||
function getInitials(email: string): string {
|
||||
const prefix = email.split("@")[0];
|
||||
const parts = prefix.split(/[._-]/);
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
}
|
||||
return prefix.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export function AckButton({ particleId, acks }: AckButtonProps) {
|
||||
const currentEmail = useAuthStore((s) => s.user?.email);
|
||||
const ackParticle = useAppStore((s) => s.ackParticle);
|
||||
const hasAcked = acks.some((a) => a.email === currentEmail);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (hasAcked || !currentEmail) return;
|
||||
ackParticle(particleId, currentEmail);
|
||||
apiClient.ackParticle(particleId).catch(() => {});
|
||||
},
|
||||
[hasAcked, currentEmail, particleId, ackParticle],
|
||||
);
|
||||
|
||||
const displayedAcks = acks.slice(0, 3);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"flex h-10 w-10 flex-col items-center justify-center rounded-full bg-black/30 backdrop-blur-sm transition-colors",
|
||||
hasAcked
|
||||
? "text-red-500"
|
||||
: "text-white hover:bg-black/40",
|
||||
)}
|
||||
>
|
||||
<Heart
|
||||
className="h-4 w-4"
|
||||
fill={hasAcked ? "currentColor" : "none"}
|
||||
/>
|
||||
<span className="mt-0.5 text-[10px] font-medium leading-none">
|
||||
{acks.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{displayedAcks.length > 0 && (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
{displayedAcks.map((ack) => (
|
||||
<Tooltip key={ack.email}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-white/20 text-[10px] font-medium text-white backdrop-blur-sm">
|
||||
{getInitials(ack.email)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>{ack.email}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { MediaParticleData, StreamParticle } from "@/api/types";
|
||||
import { apiClient } from "@/api/client";
|
||||
import { usePlaybackStore } from "@/stores/playback-store";
|
||||
@@ -17,9 +17,13 @@ export function MediaParticleView({
|
||||
(s) => s.downloadUrlCache[particle.id],
|
||||
);
|
||||
const cacheDownloadUrl = usePlaybackStore((s) => s.cacheDownloadUrl);
|
||||
const paused = usePlaybackStore((s) => s.paused);
|
||||
const [url, setUrl] = useState<string | null>(cachedUrl ?? null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (cachedUrl) {
|
||||
setUrl(cachedUrl);
|
||||
@@ -43,6 +47,17 @@ export function MediaParticleView({
|
||||
};
|
||||
}, [particle.id, cachedUrl, cacheDownloadUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = videoRef.current ?? audioRef.current;
|
||||
if (!el) return;
|
||||
|
||||
if (paused) {
|
||||
el.pause();
|
||||
} else {
|
||||
el.play().catch(() => {});
|
||||
}
|
||||
}, [paused]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex items-center justify-center text-sm">
|
||||
@@ -61,18 +76,19 @@ export function MediaParticleView({
|
||||
if (isAudio) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<audio src={url} autoPlay onEnded={onEnded} controls />
|
||||
<audio ref={audioRef} src={url} autoPlay onEnded={onEnded} controls />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={url}
|
||||
autoPlay
|
||||
playsInline
|
||||
onEnded={onEnded}
|
||||
className="h-full w-full object-contain"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { usePlaybackStore } from "@/stores/playback-store";
|
||||
import { MediaParticleView } from "./media-particle-view";
|
||||
import { TextParticleView } from "./text-particle-view";
|
||||
import { FallbackParticleView } from "./fallback-particle-view";
|
||||
import { AckButton } from "./ack-button";
|
||||
|
||||
interface ParticleRendererProps {
|
||||
particle: StreamParticle;
|
||||
@@ -53,6 +54,9 @@ export function ParticleRenderer({
|
||||
onClick={handleClick}
|
||||
>
|
||||
{renderContent()}
|
||||
<div className="absolute right-4 bottom-16">
|
||||
<AckButton particleId={particle.id} acks={particle.acks} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
|
||||
interface PlaybackControlsProps {
|
||||
total: number;
|
||||
@@ -7,8 +6,6 @@ interface PlaybackControlsProps {
|
||||
onGoTo: (index: number) => void;
|
||||
}
|
||||
|
||||
const DOT_THRESHOLD = 15;
|
||||
|
||||
export function PlaybackControls({
|
||||
total,
|
||||
current,
|
||||
@@ -16,37 +13,27 @@ export function PlaybackControls({
|
||||
}: PlaybackControlsProps) {
|
||||
if (total === 0) return null;
|
||||
|
||||
if (total <= DOT_THRESHOLD) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-1 py-2">
|
||||
{Array.from({ length: total }, (_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onGoTo(i);
|
||||
}}
|
||||
className="p-1"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-2.5 rounded-full transition-all",
|
||||
i === current
|
||||
? "bg-primary w-6"
|
||||
: "bg-muted-foreground/30 hover:bg-muted-foreground/50 w-2.5",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const percent = ((current + 1) / total) * 100;
|
||||
|
||||
return (
|
||||
<div className="px-4 py-2">
|
||||
<Progress value={percent} className="h-1" />
|
||||
<div className="flex w-full items-center gap-0.5 px-1">
|
||||
{Array.from({ length: total }, (_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onGoTo(i);
|
||||
}}
|
||||
className="group relative h-3 flex-1"
|
||||
>
|
||||
{/* Track */}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-x-0 top-1 h-1 rounded-full transition-all",
|
||||
i <= current ? "bg-white/90" : "bg-white/30",
|
||||
"group-hover:h-1.5 group-hover:top-0.5",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,32 @@
|
||||
import type { StreamParticle, TextParticleData } from "@/api/types";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface TextParticleViewProps {
|
||||
particle: StreamParticle;
|
||||
}
|
||||
|
||||
function getTextStyle(length: number) {
|
||||
if (length < 50) return { size: "text-5xl", weight: "font-semibold" };
|
||||
if (length < 150) return { size: "text-3xl", weight: "font-semibold" };
|
||||
if (length < 300) return { size: "text-2xl", weight: "font-normal" };
|
||||
return { size: "text-lg", weight: "font-normal" };
|
||||
}
|
||||
|
||||
export function TextParticleView({ particle }: TextParticleViewProps) {
|
||||
const data = particle.data as TextParticleData;
|
||||
const style = getTextStyle(data.content.length);
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full w-full">
|
||||
<div className="flex min-h-full items-center justify-center p-8">
|
||||
<p className="max-w-2xl text-center text-2xl leading-relaxed">
|
||||
{data.content}
|
||||
</p>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 p-8">
|
||||
<p
|
||||
className={cn(
|
||||
"max-w-2xl text-center leading-relaxed text-white",
|
||||
style.size,
|
||||
style.weight,
|
||||
)}
|
||||
>
|
||||
{data.content}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user