enhance: more like zoom UX
This commit is contained in:
+17
-2
@@ -30,7 +30,8 @@ export type VoicingMembers = {
|
|||||||
ids: number[],
|
ids: number[],
|
||||||
byId: {
|
byId: {
|
||||||
[key: number]: VoicingMemberInfo
|
[key: number]: VoicingMemberInfo
|
||||||
}
|
},
|
||||||
|
pin?: number
|
||||||
}
|
}
|
||||||
export type VoiceInfo = {
|
export type VoiceInfo = {
|
||||||
memberCount: number
|
memberCount: number
|
||||||
@@ -142,6 +143,12 @@ const voiceSlice = createSlice({
|
|||||||
const { uid, info } = payload;
|
const { uid, info } = payload;
|
||||||
state.voicingMembers.byId[uid] = { ...state.voicingMembers.byId[uid], ...info };
|
state.voicingMembers.byId[uid] = { ...state.voicingMembers.byId[uid], ...info };
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
updatePin(state, { payload }: PayloadAction<{ uid: number, action: "pin" | "unpin" }>) {
|
||||||
|
const idx = state.voicingMembers.ids.findIndex((uid) => uid == payload.uid);
|
||||||
|
if (idx > -1) {
|
||||||
|
state.voicingMembers.pin = payload.action == "pin" ? payload.uid : undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
extraReducers: (builder) => {
|
extraReducers: (builder) => {
|
||||||
@@ -149,6 +156,14 @@ const voiceSlice = createSlice({
|
|||||||
// reset voicing info
|
// reset voicing info
|
||||||
if (window.VOICE_CLIENT) {
|
if (window.VOICE_CLIENT) {
|
||||||
window.VOICE_CLIENT.leave();
|
window.VOICE_CLIENT.leave();
|
||||||
|
Object.entries(window.VOICE_TRACK_MAP).forEach(([uid, track]) => {
|
||||||
|
track?.close();
|
||||||
|
delete window.VOICE_TRACK_MAP[+uid];
|
||||||
|
});
|
||||||
|
Object.entries(window.VIDEO_TRACK_MAP).forEach(([uid, track]) => {
|
||||||
|
track?.close();
|
||||||
|
delete window.VOICE_TRACK_MAP[+uid];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
state.voicing = null;
|
state.voicing = null;
|
||||||
state.voicingMembers = {
|
state.voicingMembers = {
|
||||||
@@ -159,5 +174,5 @@ const voiceSlice = createSlice({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { updateConnectionState, addVoiceMember, removeVoiceMember, upsertVoiceList, updateVoicingInfo, updateVoicingNetworkQuality, updateMuteStatus, updateVoicingMember, updateDeafenStatus } = voiceSlice.actions;
|
export const { updatePin, updateConnectionState, addVoiceMember, removeVoiceMember, upsertVoiceList, updateVoicingInfo, updateVoicingNetworkQuality, updateMuteStatus, updateVoicingMember, updateDeafenStatus } = voiceSlice.actions;
|
||||||
export default voiceSlice.reducer;
|
export default voiceSlice.reducer;
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ type Props = {
|
|||||||
|
|
||||||
const Dashboard = ({ context = "channel", id, visible }: Props) => {
|
const Dashboard = ({ context = "channel", id, visible }: Props) => {
|
||||||
const { voicingInfo, setMute, leave, closeCamera, openCamera, startShareScreen, stopShareScreen } = useVoice({ id, context });
|
const { voicingInfo, setMute, leave, closeCamera, openCamera, startShareScreen, stopShareScreen } = useVoice({ id, context });
|
||||||
// const dispatch = useDispatch();
|
|
||||||
|
|
||||||
|
|
||||||
return <div className={`h-full flex-col gap-1 w-[226px] overflow-y-scroll overflow-x-hidden p-2 shadow-[inset_1px_0px_0px_rgba(0,_0,_0,_0.1)] ${visible ? "flex" : "hidden"}`}>
|
return <div className={`h-full flex-col gap-1 w-[226px] overflow-y-scroll overflow-x-hidden p-2 shadow-[inset_1px_0px_0px_rgba(0,_0,_0,_0.1)] ${visible ? "flex" : "hidden"}`}>
|
||||||
<VoiceManagement id={id} context={context} info={voicingInfo} setMute={setMute} leave={leave} closeCamera={closeCamera} openCamera={openCamera} startShareScreen={startShareScreen} stopShareScreen={stopShareScreen} />
|
<VoiceManagement id={id} context={context} info={voicingInfo} setMute={setMute} leave={leave} closeCamera={closeCamera} openCamera={openCamera} startShareScreen={startShareScreen} stopShareScreen={stopShareScreen} />
|
||||||
</div>;
|
</div>;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import clsx from 'clsx';
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { VoicingInfo } from '../../../app/slices/voice';
|
import { VoicingInfo, updatePin } from '../../../app/slices/voice';
|
||||||
import { useAppSelector } from '../../../app/store';
|
import { useAppSelector } from '../../../app/store';
|
||||||
import Avatar from '../../../common/component/Avatar';
|
import Avatar from '../../../common/component/Avatar';
|
||||||
// import IconHeadphone from '../../../assets/icons/sound.on.svg';
|
// import IconHeadphone from '../../../assets/icons/sound.on.svg';
|
||||||
@@ -49,9 +49,12 @@ const VoiceManagement = ({ id, context, info, setMute, leave, closeCamera, openC
|
|||||||
playAgoraVideo(id);
|
playAgoraVideo(id);
|
||||||
});
|
});
|
||||||
}, [voicingMembers.ids]);
|
}, [voicingMembers.ids]);
|
||||||
const handleFullscreen = () => {
|
const handleFullscreen = (uid?: number) => {
|
||||||
if (context == "channel") {
|
if (context == "channel") {
|
||||||
dispatch(updateChannelVisibleAside({ id, aside: "voice_fullscreen" }));
|
dispatch(updateChannelVisibleAside({ id, aside: "voice_fullscreen" }));
|
||||||
|
if (uid) {
|
||||||
|
dispatch(updatePin({ uid, action: "pin" }));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (!info) return null;
|
if (!info) return null;
|
||||||
@@ -103,7 +106,7 @@ const VoiceManagement = ({ id, context, info, setMute, leave, closeCamera, openC
|
|||||||
{muted ? <IconMicOff className="w-4 fill-gray-500 dark:fill-gray-400" /> : <IconMic className="w-4 fill-gray-500 dark:fill-gray-400" />}
|
{muted ? <IconMicOff className="w-4 fill-gray-500 dark:fill-gray-400" /> : <IconMic className="w-4 fill-gray-500 dark:fill-gray-400" />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id={`CAMERA_${uid}`} className="w-[98%] rounded overflow-hidden m-auto">
|
<div onDoubleClick={handleFullscreen.bind(null, uid)} id={`CAMERA_${uid}`} className="w-[98%] rounded overflow-hidden m-auto">
|
||||||
{/* camera placeholder */}
|
{/* camera placeholder */}
|
||||||
</div>
|
</div>
|
||||||
</li>;
|
</li>;
|
||||||
@@ -128,7 +131,7 @@ const VoiceManagement = ({ id, context, info, setMute, leave, closeCamera, openC
|
|||||||
</li>
|
</li>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip tip={"Fullscreen"} placement="top">
|
<Tooltip tip={"Fullscreen"} placement="top">
|
||||||
<li role={"button"} onClick={handleFullscreen} className="py-2 px-3 rounded bg-gray-100 dark:bg-gray-900">
|
<li role={"button"} onClick={handleFullscreen.bind(null, undefined)} className="py-2 px-3 rounded bg-gray-100 dark:bg-gray-900">
|
||||||
<IconFullscreen className="fill-gray-700 dark:fill-gray-300" />
|
<IconFullscreen className="fill-gray-700 dark:fill-gray-300" />
|
||||||
</li>
|
</li>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { updateChannelVisibleAside } from '../../app/slices/footprint';
|
|||||||
import Tooltip from '../../common/component/Tooltip';
|
import Tooltip from '../../common/component/Tooltip';
|
||||||
import { useVoice } from '../../common/component/Voice';
|
import { useVoice } from '../../common/component/Voice';
|
||||||
import StyledButton from '../../common/component/styled/Button';
|
import StyledButton from '../../common/component/styled/Button';
|
||||||
|
import { updatePin } from '../../app/slices/voice';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
context: ChatContext,
|
context: ChatContext,
|
||||||
@@ -25,9 +26,8 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const VoiceFullscreen = ({ id, context }: Props) => {
|
const VoiceFullscreen = ({ id, context }: Props) => {
|
||||||
const [speakingIndex, setSpeakingIndex] = useState(0);
|
|
||||||
const [pin, setPin] = useState<number | undefined>(undefined);
|
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
const [speakingUid, setSpeakingUid] = useState(0);
|
||||||
const { t } = useTranslation("chat");
|
const { t } = useTranslation("chat");
|
||||||
const { voicingInfo, setMute, leave, closeCamera, openCamera, startShareScreen, stopShareScreen } = useVoice({ id, context });
|
const { voicingInfo, setMute, leave, closeCamera, openCamera, startShareScreen, stopShareScreen } = useVoice({ id, context });
|
||||||
const { name, userData, voicingMembers } = useAppSelector(store => {
|
const { name, userData, voicingMembers } = useAppSelector(store => {
|
||||||
@@ -39,12 +39,12 @@ const VoiceFullscreen = ({ id, context }: Props) => {
|
|||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const ids = voicingMembers.ids;
|
const ids = voicingMembers.ids;
|
||||||
ids.forEach((id, idx) => {
|
ids.forEach((id) => {
|
||||||
playAgoraVideo(id);
|
playAgoraVideo(id);
|
||||||
const { speakingVolume = 0 } = voicingMembers.byId[id];
|
const { speakingVolume = 0 } = voicingMembers.byId[id];
|
||||||
const speaking = speakingVolume > 50;
|
const speaking = speakingVolume > 50;
|
||||||
if (speaking) {
|
if (speaking) {
|
||||||
setSpeakingIndex(idx);
|
setSpeakingUid(id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -54,19 +54,29 @@ const VoiceFullscreen = ({ id, context }: Props) => {
|
|||||||
dispatch(updateChannelVisibleAside({ id, aside: "voice" }));
|
dispatch(updateChannelVisibleAside({ id, aside: "voice" }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDoubleClick = (evt: MouseEvent<HTMLLIElement>) => {
|
||||||
|
const uid = evt.currentTarget.dataset.uid;
|
||||||
|
if (uid) {
|
||||||
|
dispatch(updatePin({ uid: +uid, action: "pin" }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handlePin = (evt: MouseEvent<HTMLButtonElement>) => {
|
const handlePin = (evt: MouseEvent<HTMLButtonElement>) => {
|
||||||
const idx = evt.currentTarget.dataset.idx ?? "";
|
const uid = evt.currentTarget.dataset.uid ?? "";
|
||||||
if (idx == "undefined") {
|
const action = evt.currentTarget.dataset.action ?? "pin";
|
||||||
setPin(undefined);
|
if (action == "unpin") {
|
||||||
|
dispatch(updatePin({ uid: +uid, action }));
|
||||||
} else {
|
} else {
|
||||||
setPin(parseInt(idx));
|
dispatch(updatePin({ uid: +uid, action: "pin" }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (!voicingInfo) return null;
|
if (!voicingInfo) return null;
|
||||||
|
const pinUid = voicingMembers.pin;
|
||||||
const _name = context == "channel" ? `# ${name}` : `@ ${name}`;
|
const _name = context == "channel" ? `# ${name}` : `@ ${name}`;
|
||||||
const members = voicingMembers.ids;
|
const members = voicingMembers.ids;
|
||||||
const membersData = voicingMembers.byId;
|
const membersData = voicingMembers.byId;
|
||||||
const hasPin = typeof pin !== "undefined";
|
const hasPin = typeof pinUid !== "undefined";
|
||||||
const { muted, video, shareScreen } = voicingInfo;
|
const { muted, video, shareScreen } = voicingInfo;
|
||||||
return (
|
return (
|
||||||
<div className='h-full bg-black text-gray-300 flex flex-col justify-between rounded-r-2xl'>
|
<div className='h-full bg-black text-gray-300 flex flex-col justify-between rounded-r-2xl'>
|
||||||
@@ -74,19 +84,19 @@ const VoiceFullscreen = ({ id, context }: Props) => {
|
|||||||
<div className="px-7 py-6 flex justify-between">
|
<div className="px-7 py-6 flex justify-between">
|
||||||
<span className='text-sm font-semibold'>{_name}</span>
|
<span className='text-sm font-semibold'>{_name}</span>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<IconExitScreen role="button" onClick={handleExitFullscreen} className="fill-gray-700 dark:fill-gray-300" />
|
<IconExitScreen role="button" onClick={handleExitFullscreen} className="fill-gray-300" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* middle */}
|
{/* middle */}
|
||||||
<ul className='flex grow justify-center items-end relative gap-2'>
|
<ul className='flex grow justify-center items-end relative gap-2'>
|
||||||
{members.map((uid, idx) => {
|
{members.map((uid) => {
|
||||||
const curr = userData[uid];
|
const curr = userData[uid];
|
||||||
if (!curr) return null;
|
if (!curr) return null;
|
||||||
const { muted, speakingVolume = 0, shareScreen } = membersData[uid];
|
const { muted, speakingVolume = 0, shareScreen } = membersData[uid];
|
||||||
const speaking = speakingVolume > 50;
|
const speaking = speakingVolume > 50;
|
||||||
const special = hasPin ? pin == idx : idx == speakingIndex;
|
const special = hasPin ? pinUid == uid : uid == speakingUid;
|
||||||
const disablePin = special && !hasPin;
|
const disablePin = special && !hasPin;
|
||||||
return <li key={uid} className={clsx('bg-gray-700 group', special ? "absolute left-0 top-0 w-full h-[calc(100%_-_110px)] flex-center" : "relative rounded-lg py-1.5 px-12")}>
|
return <li key={uid} data-uid={uid} onDoubleClick={handleDoubleClick} className={clsx('bg-gray-700 group', special ? "absolute left-0 top-0 w-full h-[calc(100%_-_110px)] flex-center" : "relative rounded-lg py-1.5 px-12")}>
|
||||||
<div className={clsx("w-20 h-20 flex shrink-0 relative transition-opacity")}>
|
<div className={clsx("w-20 h-20 flex shrink-0 relative transition-opacity")}>
|
||||||
{speaking && <div className={clsx("absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-0 rounded-full bg-green-500 animate-speaking", special ? "w-[88px] h-[88px]" : "w-[86px] h-[86px]")}></div>}
|
{speaking && <div className={clsx("absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 opacity-0 rounded-full bg-green-500 animate-speaking", special ? "w-[88px] h-[88px]" : "w-[86px] h-[86px]")}></div>}
|
||||||
<Avatar
|
<Avatar
|
||||||
@@ -99,9 +109,12 @@ const VoiceFullscreen = ({ id, context }: Props) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{shareScreen ? <div className={clsx("w-1 h-1 absolute z-40 rounded-full bg-green-700/60", special ? "top-2 left-2" : "top-1 left-1 px-2")} /> : null}
|
{shareScreen ? <div className={clsx("w-1 h-1 absolute z-40 rounded-full bg-green-700/60", special ? "top-2 left-2" : "top-1 left-1 px-2")} /> : null}
|
||||||
{!disablePin && <button data-idx={special ? "undefined" : idx} role={"button"} onClick={handlePin} className={clsx("absolute left-1 top-1 z-40 p-1 rounded bg-black/50", special ? "" : "invisible group-hover:visible")}>
|
{!disablePin &&
|
||||||
{special ? <IconPin className="w-3 h-3 fill-gray-700 dark:fill-gray-300" /> : <IconPin className="w-3 h-3 fill-gray-700 dark:fill-gray-300" />}
|
<Tooltip tip={"Remove pin"} disabled={!special} placement="right">
|
||||||
</button>}
|
<button data-uid={uid} data-action={special ? "unpin" : "pin"} role={"button"} onClick={handlePin} className={clsx("absolute left-1 top-1 z-40 p-1 rounded bg-black/50", special ? "" : "invisible group-hover:visible")}>
|
||||||
|
{special ? <IconPin className="w-3 h-3 fill-gray-300" /> : <IconPin className="w-3 h-3 fill-gray-300" />}
|
||||||
|
</button>
|
||||||
|
</Tooltip>}
|
||||||
<span className={clsx("text-gray-300 bg-black/50 rounded-lg absolute z-40", special ? "left-2 bottom-2 px-2 py-1 text-sm " : "left-1 bottom-1 p-1 text-xs")} title={curr?.name}>
|
<span className={clsx("text-gray-300 bg-black/50 rounded-lg absolute z-40", special ? "left-2 bottom-2 px-2 py-1 text-sm " : "left-1 bottom-1 p-1 text-xs")} title={curr?.name}>
|
||||||
{curr?.name}
|
{curr?.name}
|
||||||
</span>
|
</span>
|
||||||
@@ -129,7 +142,7 @@ const VoiceFullscreen = ({ id, context }: Props) => {
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip tip={"Share Screen"} placement="top">
|
<Tooltip tip={"Share Screen"} placement="top">
|
||||||
<li role={"button"} onClick={shareScreen ? stopShareScreen : startShareScreen} className={clsx("flex-center py-1 px-3 rounded ", shareScreen ? "bg-green-700" : "bg-gray-100 dark:bg-gray-900")}>
|
<li role={"button"} onClick={shareScreen ? stopShareScreen : startShareScreen} className={clsx("flex-center py-1 px-3 rounded ", shareScreen ? "bg-green-700" : "bg-gray-100 dark:bg-gray-900")}>
|
||||||
<IconScreen className="fill-gray-700 dark:fill-gray-300 w-6 h-6" />
|
<IconScreen className={clsx("dark:fill-gray-300 w-6 h-6", shareScreen ? "fill-gray-300" : "fill-gray-700")} />
|
||||||
</li>
|
</li>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<StyledButton onClick={leave} className='bg-red-600 hover:!bg-red-700 text-center'>
|
<StyledButton onClick={leave} className='bg-red-600 hover:!bg-red-700 text-center'>
|
||||||
|
|||||||
Reference in New Issue
Block a user