feat(mobile): add full-screen voice view with controls

This commit is contained in:
Jannis Braun
2026-03-17 15:47:41 +01:00
parent 913b4c7365
commit 206a0491ea
2 changed files with 220 additions and 1 deletions
@@ -22,7 +22,7 @@ import { MobileYouScreen } from './MobileYouScreen';
import { MobileChatScreen } from './MobileChatScreen';
import { MobileSettingsScreen } from './MobileSettingsScreen';
import { MobileVoiceMiniBar } from './MobileVoiceMiniBar';
const MobileVoiceFullScreen = () => <PlaceholderScreen label="Voice" />;
import { MobileVoiceFullScreen } from './MobileVoiceFullScreen';
const screenMap: Record<string, (params?: Record<string, string>) => React.ReactNode> = {
'channel-chat': (params) => <MobileChatScreen params={params} />,
@@ -0,0 +1,219 @@
import React from 'react';
import { useUIStore } from '../../stores/uiStore';
import { useVoiceStore } from '../../stores/voiceStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useAuthStore } from '../../stores/authStore';
import { Avatar } from '../ui/Avatar';
export function MobileVoiceFullScreen() {
const popMobileScreen = useUIStore((s) => s.popMobileScreen);
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const isMuted = useVoiceStore((s) => s.isMuted);
const isDeafened = useVoiceStore((s) => s.isDeafened);
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
const toggleMute = useVoiceStore((s) => s.toggleMute);
const toggleDeafen = useVoiceStore((s) => s.toggleDeafen);
const toggleCamera = useVoiceStore((s) => s.toggleCamera);
const toggleScreenShare = useVoiceStore((s) => s.toggleScreenShare);
const leaveChannel = useVoiceStore((s) => s.leaveChannel);
const voiceUsers = useVoiceStore((s) => s.voiceUsers);
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
const channels = useSpaceStore((s) => s.channels);
const dmChannels = useSpaceStore((s) => s.dmChannels);
const spaces = useSpaceStore((s) => s.spaces);
const channelToSpaceMap = useSpaceStore((s) => s.channelToSpaceMap);
const members = useSpaceStore((s) => s.members);
const authUser = useAuthStore((s) => s.user);
if (!currentVoiceChannelId) {
popMobileScreen();
return null;
}
const isDmCall = currentVoiceChannelId.startsWith('dm-');
let channelName = 'Voice Call';
let spaceName = '';
if (isDmCall) {
const dmId = currentVoiceChannelId.replace('dm-', '');
const dm = dmChannels.find(d => d.id === dmId);
if (dm) {
const others = dm.members.filter(m => m.id !== authUser?.id);
channelName = others.map(m => m.displayName ?? m.username).join(', ');
}
} else {
const ch = channels.find(c => c.id === currentVoiceChannelId);
if (ch) {
channelName = ch.name;
const spaceId = channelToSpaceMap.get(ch.id);
const space = spaceId ? spaces.find(s => s.id === spaceId) : null;
if (space) spaceName = space.name;
}
}
const participantIds = voiceUsers.get(currentVoiceChannelId) || [];
// Resolve participant display info from members list or DM members
const getParticipantInfo = (userId: string) => {
const member = members.find(m => m.userId === userId);
if (member) {
return {
name: member.nickname ?? member.displayName ?? member.username,
avatar: member.avatar ? `/api/uploads/${member.avatar}` : null,
avatarColor: member.avatarColor,
};
}
// Check DM members
for (const dm of dmChannels) {
const dmMember = dm.members.find(m => m.id === userId);
if (dmMember) {
return {
name: dmMember.displayName ?? dmMember.username,
avatar: dmMember.avatar ? `/api/uploads/${dmMember.avatar}` : null,
avatarColor: dmMember.avatarColor,
};
}
}
return { name: 'User', avatar: null, avatarColor: null };
};
const handleDisconnect = () => {
leaveChannel();
popMobileScreen();
};
return (
<div className="flex flex-col h-full bg-surface-base">
{/* Header */}
<header className="h-12 flex items-center gap-2 px-3 border-b border-border-soft shrink-0">
<button onClick={popMobileScreen} className="w-8 h-8 flex items-center justify-center text-txt-secondary hover:text-txt-primary">
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
</svg>
</button>
<div className="flex-1 min-w-0">
<h1 className="text-sm font-semibold text-txt-primary truncate">{channelName}</h1>
{spaceName && <p className="text-[11px] text-txt-tertiary truncate">{spaceName}</p>}
</div>
<span className="text-xs text-txt-tertiary">{participantIds.length} connected</span>
</header>
{/* Participant grid */}
<div className="flex-1 overflow-y-auto p-4">
<div className={`grid gap-3 ${
participantIds.length <= 2 ? 'grid-cols-1' : 'grid-cols-2'
}`}>
{participantIds.map(userId => {
const info = getParticipantInfo(userId);
const state = voiceUserStates.get(userId);
const isMe = userId === authUser?.id;
return (
<div
key={userId}
className={`rounded-xl bg-surface-channel p-4 flex flex-col items-center gap-3 ${
participantIds.length <= 2 ? 'py-8' : 'py-4'
}`}
>
<div className="relative">
<Avatar
src={info.avatar}
name={info.name}
avatarColor={info.avatarColor}
size={participantIds.length <= 2 ? 80 : 56}
/>
{state?.isMuted && (
<div className="absolute -bottom-1 -right-1 w-6 h-6 rounded-full bg-accent-rose/90 flex items-center justify-center">
<svg className="w-3.5 h-3.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M3 3l18 18" />
</svg>
</div>
)}
</div>
<span className="text-sm text-txt-primary font-medium truncate max-w-full">
{info.name}{isMe ? ' (You)' : ''}
</span>
</div>
);
})}
</div>
{participantIds.length === 0 && (
<div className="flex items-center justify-center h-40 text-txt-tertiary text-sm">
No one else is here yet
</div>
)}
</div>
{/* Control bar */}
<div className="glass-bubble mx-2 mb-2 rounded-2xl flex items-center justify-center gap-4 px-4 py-3 shrink-0"
style={{ marginBottom: 'calc(0.5rem + env(safe-area-inset-bottom))' }}
>
{/* Mute */}
<button
onClick={toggleMute}
className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${
isMuted ? 'bg-accent-rose/20 text-accent-rose' : 'bg-surface-elevated text-txt-primary hover:bg-interactive-hover'
}`}
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 18.75a6 6 0 006-6v-1.5m-6 7.5a6 6 0 01-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 01-3-3V4.5a3 3 0 116 0v8.25a3 3 0 01-3 3z" />
{isMuted && <path strokeLinecap="round" strokeLinejoin="round" d="M3 3l18 18" />}
</svg>
</button>
{/* Deafen */}
<button
onClick={toggleDeafen}
className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${
isDeafened ? 'bg-accent-rose/20 text-accent-rose' : 'bg-surface-elevated text-txt-primary hover:bg-interactive-hover'
}`}
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.114 5.636a9 9 0 010 12.728M16.463 8.288a5.25 5.25 0 010 7.424M6.75 8.25l4.72-4.72a.75.75 0 011.28.53v15.88a.75.75 0 01-1.28.53l-4.72-4.72H4.51c-.88 0-1.704-.507-1.938-1.354A9.01 9.01 0 012.25 12c0-.83.112-1.633.322-2.396C2.806 8.756 3.63 8.25 4.51 8.25H6.75z" />
{isDeafened && <path strokeLinecap="round" strokeLinejoin="round" d="M3 3l18 18" />}
</svg>
</button>
{/* Camera */}
<button
onClick={toggleCamera}
className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${
isCameraOn ? 'bg-accent-mint/20 text-accent-mint' : 'bg-surface-elevated text-txt-primary hover:bg-interactive-hover'
}`}
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" d="M15.75 10.5l4.72-4.72a.75.75 0 011.28.53v11.38a.75.75 0 01-1.28.53l-4.72-4.72M4.5 18.75h9.75a2.25 2.25 0 002.25-2.25V7.5a2.25 2.25 0 00-2.25-2.25H4.5A2.25 2.25 0 002.25 7.5v9A2.25 2.25 0 004.5 18.75z" />
</svg>
</button>
{/* Screen share */}
<button
onClick={toggleScreenShare}
className={`w-12 h-12 rounded-full flex items-center justify-center transition-colors ${
isScreenSharing ? 'bg-accent-mint/20 text-accent-mint' : 'bg-surface-elevated text-txt-primary hover:bg-interactive-hover'
}`}
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25m18 0A2.25 2.25 0 0018.75 3H5.25A2.25 2.25 0 003 5.25m18 0V12a9 9 0 01-9 9m0 0a9 9 0 01-9-9" />
</svg>
</button>
{/* Disconnect */}
<button
onClick={handleDisconnect}
className="w-12 h-12 rounded-full bg-accent-rose flex items-center justify-center text-white hover:bg-accent-rose/80 transition-colors"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
</svg>
</button>
</div>
</div>
);
}