This commit is contained in:
@@ -1,250 +0,0 @@
|
||||
.container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: calc(var(--z-index-modal, 10000) + 1);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow:
|
||||
0 8px 32px var(--voice-shadow-strong),
|
||||
0 2px 8px var(--voice-shadow-soft);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
background-color: var(--voice-surface-2);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.container:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.videoWrapper {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.videoWrapper video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background-color: var(--voice-surface-0);
|
||||
border-radius: inherit;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.screenShareVideo {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* ── Top gradient + return-to-call button ───────────────────── */
|
||||
|
||||
.headerGradient {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 48px;
|
||||
background: linear-gradient(to bottom, var(--voice-overlay-strong) 0%, transparent 100%);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
padding: 8px 10px;
|
||||
pointer-events: auto;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.returnToCallButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--voice-text-strong);
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid transparent;
|
||||
transition: border-color 150ms;
|
||||
}
|
||||
|
||||
.returnToCallButton:hover {
|
||||
border-bottom-color: currentColor;
|
||||
}
|
||||
|
||||
/* ── Bottom gradient + streamer name ───────────────────────── */
|
||||
|
||||
.footerGradient {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 48px;
|
||||
background: linear-gradient(to top, var(--voice-overlay-strong) 0%, transparent 100%);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding: 8px 10px;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.streamerName {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--voice-text-strong);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* ── Resize handles ─────────────────────────────────────────── */
|
||||
|
||||
.resizeHandleTop,
|
||||
.resizeHandleBottom,
|
||||
.resizeHandleLeft,
|
||||
.resizeHandleRight,
|
||||
.resizeHandleTopLeft,
|
||||
.resizeHandleTopRight,
|
||||
.resizeHandleBottomLeft,
|
||||
.resizeHandleBottomRight {
|
||||
position: absolute;
|
||||
border: none;
|
||||
background: transparent;
|
||||
z-index: 4;
|
||||
pointer-events: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.resizeHandleTop {
|
||||
top: 0;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
height: 8px;
|
||||
cursor: n-resize;
|
||||
}
|
||||
|
||||
.resizeHandleBottom {
|
||||
bottom: 0;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
height: 8px;
|
||||
cursor: s-resize;
|
||||
}
|
||||
|
||||
.resizeHandleLeft {
|
||||
top: 16px;
|
||||
left: 0;
|
||||
bottom: 16px;
|
||||
width: 8px;
|
||||
cursor: w-resize;
|
||||
}
|
||||
|
||||
.resizeHandleRight {
|
||||
top: 16px;
|
||||
right: 0;
|
||||
bottom: 16px;
|
||||
width: 8px;
|
||||
cursor: e-resize;
|
||||
}
|
||||
|
||||
.resizeHandleTopLeft {
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: nw-resize;
|
||||
}
|
||||
|
||||
.resizeHandleTopRight {
|
||||
top: 0;
|
||||
right: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: ne-resize;
|
||||
}
|
||||
|
||||
.resizeHandleBottomLeft {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: sw-resize;
|
||||
}
|
||||
|
||||
.resizeHandleBottomRight {
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: se-resize;
|
||||
}
|
||||
|
||||
.pipContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
padding: 12px 14px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pipLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.pipChannel {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pipActions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.pipPrimary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: var(--brand-primary, #4641d9);
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pipPrimary:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.pipDanger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background-color: var(--status-danger, #ed4245);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pipDanger:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
/**
|
||||
* PiPOverlay — floating voice-call status widget shown when the user
|
||||
* is connected to a voice channel but has navigated to a different
|
||||
* channel / DM / route. Presents the channel name, a "Return to
|
||||
* channel" button, and a disconnect button. The widget is draggable
|
||||
* via pointer events.
|
||||
*
|
||||
* The new UI's screen-share video PiP required live LiveKit track
|
||||
* binding from a MobX store; our Convex/React voice context doesn't
|
||||
* expose that the same way, so this is a simpler status widget
|
||||
* focused on the common case: "I'm in a call, I want to get back."
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { PhoneDisconnect, ArrowRight } from '@phosphor-icons/react';
|
||||
import { useVoice } from '../../contexts/VoiceContext';
|
||||
import styles from './PiPOverlay.module.css';
|
||||
|
||||
interface Box {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const WIDTH = 260;
|
||||
const HEIGHT = 96;
|
||||
|
||||
function clamp(v: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, v));
|
||||
}
|
||||
|
||||
export function PiPOverlay() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const voice = useVoice() as any;
|
||||
|
||||
const [box, setBox] = useState<Box>(() => ({
|
||||
x: Math.max(16, window.innerWidth - WIDTH - 24),
|
||||
y: Math.max(16, window.innerHeight - HEIGHT - 24),
|
||||
}));
|
||||
const dragRef = useRef<{ startX: number; startY: number; startBox: Box } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMove = (e: PointerEvent) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag) return;
|
||||
const dx = e.clientX - drag.startX;
|
||||
const dy = e.clientY - drag.startY;
|
||||
setBox({
|
||||
x: clamp(drag.startBox.x + dx, 0, window.innerWidth - WIDTH),
|
||||
y: clamp(drag.startBox.y + dy, 0, window.innerHeight - HEIGHT),
|
||||
});
|
||||
};
|
||||
const handleUp = () => {
|
||||
dragRef.current = null;
|
||||
};
|
||||
window.addEventListener('pointermove', handleMove);
|
||||
window.addEventListener('pointerup', handleUp);
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', handleMove);
|
||||
window.removeEventListener('pointerup', handleUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const startDrag = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
// Don't initiate a drag when the pointer lands on a button.
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest('button')) return;
|
||||
e.preventDefault();
|
||||
dragRef.current = {
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
startBox: { ...box },
|
||||
};
|
||||
},
|
||||
[box],
|
||||
);
|
||||
|
||||
const activeChannelId: string | null = voice?.activeChannelId ?? null;
|
||||
const activeChannelName: string | null = voice?.activeChannelName ?? null;
|
||||
|
||||
// Hide the overlay when not in a call or when the user is already
|
||||
// looking at the active voice channel.
|
||||
if (!activeChannelId) return null;
|
||||
const onActiveChannelRoute =
|
||||
location.pathname.includes(`/${activeChannelId}`);
|
||||
if (onActiveChannelRoute) return null;
|
||||
|
||||
const handleReturn = () => {
|
||||
navigate(`/channels/home/${activeChannelId}`);
|
||||
};
|
||||
const handleDisconnect = () => {
|
||||
voice?.disconnect?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.container}
|
||||
style={{
|
||||
transform: `translate(${box.x}px, ${box.y}px)`,
|
||||
width: WIDTH,
|
||||
height: HEIGHT,
|
||||
}}
|
||||
onPointerDown={startDrag}
|
||||
>
|
||||
<div className={styles.pipContent}>
|
||||
<div className={styles.pipLabel}>In voice</div>
|
||||
<div className={styles.pipChannel}>
|
||||
#{activeChannelName || 'channel'}
|
||||
</div>
|
||||
<div className={styles.pipActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pipPrimary}
|
||||
onClick={handleReturn}
|
||||
aria-label="Return to channel"
|
||||
>
|
||||
<ArrowRight size={14} weight="bold" />
|
||||
<span>Return</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.pipDanger}
|
||||
onClick={handleDisconnect}
|
||||
aria-label="Disconnect"
|
||||
>
|
||||
<PhoneDisconnect size={14} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -60,6 +60,24 @@
|
||||
color: var(--voice-text-strong);
|
||||
}
|
||||
|
||||
.backButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--voice-text-strong);
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.backButton:active {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.channelIcon {
|
||||
color: var(--voice-text-muted);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Hash } from '@phosphor-icons/react';
|
||||
import { ArrowLeft, Hash } from '@phosphor-icons/react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useVoice } from '../../contexts/VoiceContext';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { ScreenSharePreview } from './ScreenSharePreview';
|
||||
import { VoiceGridLayout } from './VoiceGridLayout';
|
||||
import { VoiceControlBar } from './VoiceControlBar';
|
||||
@@ -22,6 +24,21 @@ interface RawParticipant {
|
||||
|
||||
export function VoiceCallView({ channelId }: VoiceCallViewProps) {
|
||||
const voice = useVoice();
|
||||
const isMobile = useIsMobile();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
// On mobile the call surface fills the whole viewport and the
|
||||
// channel list isn't visible, so expose an explicit way back.
|
||||
// Drop the last route segment (the channelId) so we land on the
|
||||
// server's nav column (e.g. /channels/home/xyz → /channels/home),
|
||||
// which `GuildsLayout` renders as the sidebar on mobile.
|
||||
const handleBack = () => {
|
||||
const segments = location.pathname.split('/').filter(Boolean);
|
||||
if (segments[0] === 'channels' && segments.length >= 3) {
|
||||
navigate(`/${segments[0]}/${segments[1]}`);
|
||||
}
|
||||
};
|
||||
|
||||
const rawParticipants: RawParticipant[] =
|
||||
(voice?.voiceStates && voice.voiceStates[channelId]) || [];
|
||||
@@ -42,6 +59,16 @@ export function VoiceCallView({ channelId }: VoiceCallViewProps) {
|
||||
<div className={`${styles.root} ${styles.pointerActive}`} data-voice-call-root>
|
||||
<div className={styles.voiceHeader}>
|
||||
<div className={styles.headerLeftSection}>
|
||||
{isMobile && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.backButton}
|
||||
onClick={handleBack}
|
||||
aria-label="Back to channel list"
|
||||
>
|
||||
<ArrowLeft size={22} weight="bold" />
|
||||
</button>
|
||||
)}
|
||||
<div className={styles.channelInfoContainer}>
|
||||
<Hash size={20} weight="bold" className={styles.channelIcon} />
|
||||
<span className={styles.channelName}>{channelName}</span>
|
||||
|
||||
Reference in New Issue
Block a user