130 lines
3.7 KiB
TypeScript
130 lines
3.7 KiB
TypeScript
import type { Component } from "solid-js";
|
|
import { Show, createEffect, onCleanup } from "solid-js";
|
|
import { MicOff, VolumeX, AlertTriangle } from "lucide-solid";
|
|
import Avatar from "../common/Avatar";
|
|
import { openProfileCard } from "../../stores/ui";
|
|
import type { VoiceMediaState } from "../../lib/types";
|
|
|
|
interface VoiceParticipantTileProps {
|
|
peer_id: string;
|
|
display_name: string;
|
|
media_state: VoiceMediaState;
|
|
stream?: MediaStream | null;
|
|
is_local?: boolean;
|
|
connectionState?: RTCPeerConnectionState;
|
|
}
|
|
|
|
const VoiceParticipantTile: Component<VoiceParticipantTileProps> = (props) => {
|
|
let videoRef: HTMLVideoElement | undefined;
|
|
|
|
// attach stream to video element when it changes
|
|
createEffect(() => {
|
|
const currentStream = props.stream;
|
|
if (videoRef && currentStream) {
|
|
videoRef.srcObject = currentStream;
|
|
}
|
|
});
|
|
|
|
// cleanup video element on unmount
|
|
onCleanup(() => {
|
|
if (videoRef) {
|
|
videoRef.srcObject = null;
|
|
}
|
|
});
|
|
|
|
const hasVideo = () => {
|
|
return (
|
|
props.stream &&
|
|
(props.media_state.video_enabled || props.media_state.screen_sharing)
|
|
);
|
|
};
|
|
|
|
// per-peer connection state ring styling
|
|
const connectionRingClass = () => {
|
|
const state = props.connectionState;
|
|
switch (state) {
|
|
case "connected":
|
|
return "ring-2 ring-green-500/70";
|
|
case "connecting":
|
|
case "new":
|
|
return "ring-2 ring-amber-400/70 animate-pulse";
|
|
case "failed":
|
|
return "ring-2 ring-red-500/80";
|
|
case "disconnected":
|
|
return "ring-2 ring-white/30";
|
|
case "closed":
|
|
default:
|
|
return "";
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
class={`relative bg-black border border-white/10 aspect-video flex items-center justify-center overflow-hidden cursor-pointer ${connectionRingClass()}`}
|
|
onClick={(e) => {
|
|
openProfileCard({
|
|
peerId: props.peer_id,
|
|
displayName: props.display_name,
|
|
anchorX: e.clientX,
|
|
anchorY: e.clientY,
|
|
});
|
|
}}
|
|
>
|
|
<Show
|
|
when={hasVideo()}
|
|
fallback={
|
|
<div class="flex flex-col items-center justify-center gap-2 p-4">
|
|
<Avatar name={props.display_name} size="xl" />
|
|
<span class="text-white text-sm font-medium truncate max-w-full">
|
|
{props.display_name}
|
|
</span>
|
|
</div>
|
|
}
|
|
>
|
|
<video
|
|
ref={videoRef}
|
|
autoplay
|
|
playsinline
|
|
muted={props.is_local}
|
|
class="w-full h-full object-cover"
|
|
/>
|
|
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-2">
|
|
<span class="text-white text-sm font-medium truncate">
|
|
{props.display_name}
|
|
</span>
|
|
</div>
|
|
</Show>
|
|
|
|
{/* media state indicators — stacked vertically to avoid overlap */}
|
|
<div class="absolute top-2 right-2 flex flex-col gap-1">
|
|
<Show when={props.media_state.muted}>
|
|
<div class="bg-black/80 p-1">
|
|
<MicOff size={16} class="text-[#FF4F00]" />
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when={props.media_state.deafened}>
|
|
<div class="bg-black/80 p-1">
|
|
<VolumeX size={16} class="text-[#FF4F00]" />
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
|
|
{/* connection failed warning */}
|
|
<Show when={props.connectionState === "failed"}>
|
|
<div class="absolute bottom-2 right-2 bg-red-900/80 p-1 rounded">
|
|
<AlertTriangle size={14} class="text-red-400" />
|
|
</div>
|
|
</Show>
|
|
|
|
<Show when={props.is_local}>
|
|
<div class="absolute top-2 left-2 bg-black/80 px-2 py-1">
|
|
<span class="text-white/60 text-xs">You</span>
|
|
</div>
|
|
</Show>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default VoiceParticipantTile;
|