tidy bottom section of stream-view

This commit is contained in:
talksik
2026-04-01 11:28:54 -07:00
parent 7c77d02ab5
commit e117f58dbe
3 changed files with 121 additions and 59 deletions
@@ -1,4 +1,3 @@
import { cn } from "@/lib/utils";
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { import {
Tooltip, Tooltip,
@@ -15,6 +14,8 @@ interface PlaybackPageIndicatorProps {
progress: number; progress: number;
onGoTo: (index: number) => void; onGoTo: (index: number) => void;
presenceBySegment?: Map<number, HumanPresence[]>; presenceBySegment?: Map<number, HumanPresence[]>;
/** Render only avatars or only tracks. Omit to render both. */
layer?: "avatars" | "tracks";
} }
export function PlaybackPageIndicator({ export function PlaybackPageIndicator({
@@ -23,42 +24,47 @@ export function PlaybackPageIndicator({
progress, progress,
onGoTo, onGoTo,
presenceBySegment, presenceBySegment,
layer,
}: PlaybackPageIndicatorProps) { }: PlaybackPageIndicatorProps) {
if (total === 0) return null; if (total === 0) return null;
const showAvatars = layer !== "tracks";
const showTracks = layer !== "avatars";
return ( return (
<div className="flex w-full items-end gap-px leading-none"> <div className="flex w-full items-end gap-px leading-none">
{Array.from({ length: total }, (_, i) => { {Array.from({ length: total }, (_, i) => {
const presence = presenceBySegment?.get(i); const presence = presenceBySegment?.get(i);
return ( return (
<div key={i} className="flex flex-1 flex-col items-stretch"> <div key={i} className="flex flex-1 flex-col items-stretch">
{/* Presence avatars above the track */} {showAvatars && presence && presence.length > 0 && (
{presence && presence.length > 0 && (
<SegmentPresenceAvatars presence={presence} /> <SegmentPresenceAvatars presence={presence} />
)} )}
<button {showTracks && (
onClick={(e) => { <button
e.stopPropagation(); onClick={(e) => {
onGoTo(i); e.stopPropagation();
}} onGoTo(i);
className="group relative block h-3 w-full"
>
{/* Dim track */}
<div className="absolute inset-x-0 bottom-0 h-[3px] bg-white/30 transition-all group-hover:h-1.5" />
{/* Fill */}
<div
className="absolute left-0 bottom-0 h-[3px] bg-white/90 transition-all group-hover:h-1.5"
style={{
width:
i < current
? "100%"
: i === current
? `${progress * 100}%`
: "0%",
transition: i === current ? "width 300ms linear" : "none",
}} }}
/> className="group relative block h-3 w-full"
</button> >
{/* Dim track */}
<div className="absolute inset-x-0 bottom-0 h-[3px] bg-white/30 transition-all group-hover:h-1.5" />
{/* Fill */}
<div
className="absolute left-0 bottom-0 h-[3px] bg-white/90 transition-all group-hover:h-1.5"
style={{
width:
i < current
? "100%"
: i === current
? `${progress * 100}%`
: "0%",
transition: i === current ? "width 300ms linear" : "none",
}}
/>
</button>
)}
</div> </div>
); );
})} })}
+89 -33
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useEffectEvent, useCallback } from "react"; import { useState, useEffect, useEffectEvent, useCallback, useRef } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useAuthStore } from "@/stores/auth-store"; import { useAuthStore } from "@/stores/auth-store";
import { apiClient } from "@/api/client"; import { apiClient } from "@/api/client";
@@ -21,7 +21,7 @@ import { RelativeTimestamp } from "@/components/relative-timestamp";
import { useStreamPlayback } from "@/hooks/use-stream-playback"; import { useStreamPlayback } from "@/hooks/use-stream-playback";
import { usePrefetchAdjacentMedia } from "@/hooks/use-prefetch-adjacent-media"; import { usePrefetchAdjacentMedia } from "@/hooks/use-prefetch-adjacent-media";
import { usePresencePositions } from "@/hooks/use-presence-positions"; import { usePresencePositions } from "@/hooks/use-presence-positions";
import { getInitials } from "@/lib/utils"; import { cn, getInitials } from "@/lib/utils";
function getParticleDisplayName(particle: Particle): string { function getParticleDisplayName(particle: Particle): string {
switch (particle.type) { switch (particle.type) {
@@ -132,6 +132,19 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
const [composeActive, setComposeActive] = useState(false); const [composeActive, setComposeActive] = useState(false);
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
// Show/hide chrome on mouse activity (YouTube-style)
const [showControls, setShowControls] = useState(true);
const idleTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const handleMouseActivity = useCallback(() => {
setShowControls(true);
clearTimeout(idleTimerRef.current);
idleTimerRef.current = setTimeout(() => setShowControls(false), 3000);
}, []);
useEffect(() => () => clearTimeout(idleTimerRef.current), []);
// Always show controls when compose is active or exit countdown is visible
const controlsVisible = showControls || composeActive || status === "ended";
const handleExitNavigate = useCallback(() => { const handleExitNavigate = useCallback(() => {
navigate(`/${networkId}`); navigate(`/${networkId}`);
}, [navigate, networkId]); }, [navigate, networkId]);
@@ -271,13 +284,15 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
} }
return ( return (
<div className="relative flex h-screen flex-col bg-black text-white"> <div
{/* Top gradient safe zone — keeps progress bar & breadcrumbs readable */} className="relative flex h-screen flex-col overflow-hidden bg-black text-white"
onMouseMove={handleMouseActivity}
onMouseLeave={() => setShowControls(false)}
>
{/* Top gradient safe zone */}
<div className="pointer-events-none absolute inset-x-0 top-0 z-[5] h-32 bg-gradient-to-b from-black/60 to-transparent" /> <div className="pointer-events-none absolute inset-x-0 top-0 z-[5] h-32 bg-gradient-to-b from-black/60 to-transparent" />
{/* Bottom gradient safe zone — keeps captions & controls readable */} {/* TopBar — always visible */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[5] h-48 bg-gradient-to-t from-black/60 to-transparent" />
<div className="z-10 absolute left-0 right-0 mt-3"> <div className="z-10 absolute left-0 right-0 mt-3">
<TopBar networkId={networkId} particle={currentParticle} streamParticle={streamParticle} /> <TopBar networkId={networkId} particle={currentParticle} streamParticle={streamParticle} />
</div> </div>
@@ -301,36 +316,77 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
onParticleCreated={onLocalParticleCreated} onParticleCreated={onLocalParticleCreated}
/> />
{/* Bottom-left: metadata */} {/* BottomBar */}
<div className="absolute bottom-0 left-0 z-10 flex items-center gap-2 px-2 pb-2 text-xs text-white/70"> <BottomBar
{exitRemainingMs !== null && ( visible={controlsVisible}
<span className="rounded-full bg-black/30 px-2 py-1 backdrop-blur-sm"> total={children.length}
Closing in {Math.ceil(exitRemainingMs / 1000)}s current={currentIndex}
</span> progress={progress}
)} onGoTo={goTo}
</div> presenceBySegment={presenceBySegment}
exitRemainingMs={exitRemainingMs}
/>
</div>
);
}
{/* Bottom-center: controls */} function BottomBar({
<div className="absolute inset-x-0 bottom-0 z-10 flex justify-center pb-3"> visible,
<ControlsIndicator type="reply" showEscape={true}> total,
<span> current,
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs"> progress,
H onGoTo,
</kbd>{" "} presenceBySegment,
huddle exitRemainingMs,
</span> }: {
</ControlsIndicator> visible: boolean;
</div> total: number;
current: number;
{/* Progress indicator — bottom */} progress: number;
<div className="z-10 absolute inset-x-0 bottom-0 mb-12"> onGoTo: (index: number) => void;
presenceBySegment: Map<number, import("@/hooks/use-presence-positions").HumanPresence[]>;
exitRemainingMs: number | null;
}) {
return (
<div className={cn(
"absolute inset-x-0 bottom-0 z-10 transition-all duration-300",
visible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-2 pointer-events-none",
)}>
{/* Presence avatars — above the blurred background */}
<PlaybackPageIndicator
total={total}
current={current}
progress={progress}
onGoTo={onGoTo}
presenceBySegment={presenceBySegment}
layer="avatars"
/>
{/* Blurred background container — tracks + controls */}
<div className="pb-3">
<PlaybackPageIndicator <PlaybackPageIndicator
total={children.length} total={total}
current={currentIndex} current={current}
progress={progress} progress={progress}
onGoTo={goTo} onGoTo={onGoTo}
presenceBySegment={presenceBySegment} layer="tracks"
/> />
<div className="flex items-center justify-center px-3 pt-2">
<ControlsIndicator type="reply" showEscape={true}>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
H
</kbd>{" "}
huddle
</span>
</ControlsIndicator>
</div>
{exitRemainingMs !== null && (
<div className="flex justify-center pt-1">
<span className="rounded-full bg-black/30 px-2 py-1 text-xs text-white/70 backdrop-blur-sm">
Closing in {Math.ceil(exitRemainingMs / 1000)}s
</span>
</div>
)}
</div> </div>
</div> </div>
); );
@@ -75,7 +75,7 @@ export function TranscriptOverlay({
return ( return (
<div className={centered <div className={centered
? "absolute inset-0 flex items-center justify-center px-6" ? "absolute inset-0 flex items-center justify-center px-6"
: "absolute bottom-17 left-0 right-0 flex justify-center px-6" : "absolute bottom-15 left-0 right-0 flex justify-center px-6"
}> }>
<p className="rounded-lg px-5 py-3 text-2xl text-center max-w-lg"> <p className="rounded-lg px-5 py-3 text-2xl text-center max-w-lg">
{activeChunk.map((word, i) => { {activeChunk.map((word, i) => {