tidy bottom section of stream-view
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user