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 {
Tooltip,
@@ -15,6 +14,8 @@ interface PlaybackPageIndicatorProps {
progress: number;
onGoTo: (index: number) => void;
presenceBySegment?: Map<number, HumanPresence[]>;
/** Render only avatars or only tracks. Omit to render both. */
layer?: "avatars" | "tracks";
}
export function PlaybackPageIndicator({
@@ -23,42 +24,47 @@ export function PlaybackPageIndicator({
progress,
onGoTo,
presenceBySegment,
layer,
}: PlaybackPageIndicatorProps) {
if (total === 0) return null;
const showAvatars = layer !== "tracks";
const showTracks = layer !== "avatars";
return (
<div className="flex w-full items-end gap-px leading-none">
{Array.from({ length: total }, (_, i) => {
const presence = presenceBySegment?.get(i);
return (
<div key={i} className="flex flex-1 flex-col items-stretch">
{/* Presence avatars above the track */}
{presence && presence.length > 0 && (
{showAvatars && presence && presence.length > 0 && (
<SegmentPresenceAvatars presence={presence} />
)}
<button
onClick={(e) => {
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",
{showTracks && (
<button
onClick={(e) => {
e.stopPropagation();
onGoTo(i);
}}
/>
</button>
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",
}}
/>
</button>
)}
</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 { useAuthStore } from "@/stores/auth-store";
import { apiClient } from "@/api/client";
@@ -21,7 +21,7 @@ import { RelativeTimestamp } from "@/components/relative-timestamp";
import { useStreamPlayback } from "@/hooks/use-stream-playback";
import { usePrefetchAdjacentMedia } from "@/hooks/use-prefetch-adjacent-media";
import { usePresencePositions } from "@/hooks/use-presence-positions";
import { getInitials } from "@/lib/utils";
import { cn, getInitials } from "@/lib/utils";
function getParticleDisplayName(particle: Particle): string {
switch (particle.type) {
@@ -132,6 +132,19 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
const [composeActive, setComposeActive] = useState(false);
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(() => {
navigate(`/${networkId}`);
}, [navigate, networkId]);
@@ -271,13 +284,15 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
}
return (
<div className="relative flex h-screen flex-col bg-black text-white">
{/* Top gradient safe zone — keeps progress bar & breadcrumbs readable */}
<div
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" />
{/* Bottom gradient safe zone — keeps captions & controls readable */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[5] h-48 bg-gradient-to-t from-black/60 to-transparent" />
{/* TopBar — always visible */}
<div className="z-10 absolute left-0 right-0 mt-3">
<TopBar networkId={networkId} particle={currentParticle} streamParticle={streamParticle} />
</div>
@@ -301,36 +316,77 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
onParticleCreated={onLocalParticleCreated}
/>
{/* Bottom-left: metadata */}
<div className="absolute bottom-0 left-0 z-10 flex items-center gap-2 px-2 pb-2 text-xs text-white/70">
{exitRemainingMs !== null && (
<span className="rounded-full bg-black/30 px-2 py-1 backdrop-blur-sm">
Closing in {Math.ceil(exitRemainingMs / 1000)}s
</span>
)}
</div>
{/* BottomBar */}
<BottomBar
visible={controlsVisible}
total={children.length}
current={currentIndex}
progress={progress}
onGoTo={goTo}
presenceBySegment={presenceBySegment}
exitRemainingMs={exitRemainingMs}
/>
</div>
);
}
{/* Bottom-center: controls */}
<div className="absolute inset-x-0 bottom-0 z-10 flex justify-center pb-3">
<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>
{/* Progress indicator — bottom */}
<div className="z-10 absolute inset-x-0 bottom-0 mb-12">
function BottomBar({
visible,
total,
current,
progress,
onGoTo,
presenceBySegment,
exitRemainingMs,
}: {
visible: boolean;
total: number;
current: number;
progress: number;
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
total={children.length}
current={currentIndex}
total={total}
current={current}
progress={progress}
onGoTo={goTo}
presenceBySegment={presenceBySegment}
onGoTo={onGoTo}
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>
);
@@ -75,7 +75,7 @@ export function TranscriptOverlay({
return (
<div className={centered
? "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">
{activeChunk.map((word, i) => {