feat(voice): add Discord-style user bar controls (#88)
Add voice state management, device picker, connection panel, and mic/headphone/settings controls to sidebar footer. Voice channels are now clickable (UI state only, no WebRTC yet). Co-authored-by: Nicholai <nicholaivogelfilms@gmail.com>
This commit is contained in:
parent
d4914c1a46
commit
143e6b2c46
@ -26,6 +26,7 @@ import { NativeShell } from "@/components/native/native-shell"
|
|||||||
import { PushNotificationRegistrar } from "@/hooks/use-native-push"
|
import { PushNotificationRegistrar } from "@/hooks/use-native-push"
|
||||||
import { DesktopShell } from "@/components/desktop/desktop-shell"
|
import { DesktopShell } from "@/components/desktop/desktop-shell"
|
||||||
import { DesktopOfflineBanner } from "@/components/desktop/offline-banner"
|
import { DesktopOfflineBanner } from "@/components/desktop/offline-banner"
|
||||||
|
import { VoiceProvider } from "@/components/voice/voice-provider"
|
||||||
|
|
||||||
export default async function DashboardLayout({
|
export default async function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
@ -45,6 +46,7 @@ export default async function DashboardLayout({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ChatProvider>
|
<ChatProvider>
|
||||||
|
<VoiceProvider>
|
||||||
<SettingsProvider>
|
<SettingsProvider>
|
||||||
<ProjectListProvider projects={projectList}>
|
<ProjectListProvider projects={projectList}>
|
||||||
<PageActionsProvider>
|
<PageActionsProvider>
|
||||||
@ -90,6 +92,7 @@ export default async function DashboardLayout({
|
|||||||
</PageActionsProvider>
|
</PageActionsProvider>
|
||||||
</ProjectListProvider>
|
</ProjectListProvider>
|
||||||
</SettingsProvider>
|
</SettingsProvider>
|
||||||
|
</VoiceProvider>
|
||||||
</ChatProvider>
|
</ChatProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,8 +19,10 @@ import { NavFiles } from "@/components/nav-files"
|
|||||||
import { NavProjects } from "@/components/nav-projects"
|
import { NavProjects } from "@/components/nav-projects"
|
||||||
import { NavConversations } from "@/components/nav-conversations"
|
import { NavConversations } from "@/components/nav-conversations"
|
||||||
import { NavUser } from "@/components/nav-user"
|
import { NavUser } from "@/components/nav-user"
|
||||||
|
import { VoicePanel } from "@/components/voice/voice-panel"
|
||||||
// settings is now a page at /dashboard/settings
|
// settings is now a page at /dashboard/settings
|
||||||
import { openFeedbackDialog } from "@/components/feedback-widget"
|
import { openFeedbackDialog } from "@/components/feedback-widget"
|
||||||
|
import { useVoiceState } from "@/hooks/use-voice-state"
|
||||||
import type { SidebarUser } from "@/lib/auth"
|
import type { SidebarUser } from "@/lib/auth"
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
@ -148,6 +150,7 @@ export function AppSidebar({
|
|||||||
readonly user: SidebarUser | null
|
readonly user: SidebarUser | null
|
||||||
}) {
|
}) {
|
||||||
const { isMobile } = useSidebar()
|
const { isMobile } = useSidebar()
|
||||||
|
const { channelId } = useVoiceState()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar collapsible="icon" {...props}>
|
<Sidebar collapsible="icon" {...props}>
|
||||||
@ -198,6 +201,7 @@ export function AppSidebar({
|
|||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
)}
|
)}
|
||||||
|
{channelId !== null && <VoicePanel />}
|
||||||
<NavUser user={user} />
|
<NavUser user={user} />
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
|
|||||||
@ -1,25 +1,35 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
import { Volume2 } from "lucide-react"
|
import { Volume2 } from "lucide-react"
|
||||||
import { Badge } from "@/components/ui/badge"
|
|
||||||
import { SidebarMenuButton } from "@/components/ui/sidebar"
|
import { SidebarMenuButton } from "@/components/ui/sidebar"
|
||||||
|
import { useVoiceState } from "@/hooks/use-voice-state"
|
||||||
|
|
||||||
type VoiceChannelStubProps = {
|
type VoiceChannelStubProps = {
|
||||||
|
readonly id: string
|
||||||
readonly name: string
|
readonly name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VoiceChannelStub({ name }: VoiceChannelStubProps) {
|
export function VoiceChannelStub({ id, name }: VoiceChannelStubProps): React.ReactElement {
|
||||||
|
const { channelId, joinChannel, leaveChannel } = useVoiceState()
|
||||||
|
const isActive = channelId === id
|
||||||
|
|
||||||
|
function handleClick(): void {
|
||||||
|
if (isActive) {
|
||||||
|
leaveChannel()
|
||||||
|
} else {
|
||||||
|
joinChannel(id, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
className="cursor-not-allowed opacity-60"
|
onClick={handleClick}
|
||||||
disabled
|
isActive={isActive}
|
||||||
tooltip={`${name} (Coming Soon)`}
|
tooltip={name}
|
||||||
>
|
>
|
||||||
<Volume2 className="h-4 w-4" />
|
<Volume2 className="h-4 w-4" />
|
||||||
<span>{name}</span>
|
<span>{name}</span>
|
||||||
<Badge variant="secondary" className="ml-auto text-[10px]">
|
|
||||||
Soon
|
|
||||||
</Badge>
|
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -279,7 +279,7 @@ export function NavConversations() {
|
|||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{voiceChannels.map((channel) => (
|
{voiceChannels.map((channel) => (
|
||||||
<SidebarMenuItem key={channel.id}>
|
<SidebarMenuItem key={channel.id}>
|
||||||
<VoiceChannelStub name={channel.name} />
|
<VoiceChannelStub id={channel.id} name={channel.name} />
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
))}
|
))}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
|
|||||||
@ -1,12 +1,18 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
|
import Link from "next/link"
|
||||||
import {
|
import {
|
||||||
IconCreditCard,
|
IconCreditCard,
|
||||||
IconDotsVertical,
|
|
||||||
IconLogout,
|
IconLogout,
|
||||||
IconNotification,
|
IconNotification,
|
||||||
IconUserCircle,
|
IconUserCircle,
|
||||||
|
IconMicrophone,
|
||||||
|
IconMicrophoneOff,
|
||||||
|
IconHeadphones,
|
||||||
|
IconHeadphonesOff,
|
||||||
|
IconSettings,
|
||||||
|
IconChevronUp,
|
||||||
} from "@tabler/icons-react"
|
} from "@tabler/icons-react"
|
||||||
|
|
||||||
import { logout } from "@/app/actions/profile"
|
import { logout } from "@/app/actions/profile"
|
||||||
@ -31,26 +37,50 @@ import {
|
|||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
useSidebar,
|
useSidebar,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover"
|
||||||
import { AccountModal } from "@/components/account-modal"
|
import { AccountModal } from "@/components/account-modal"
|
||||||
|
import { DevicePicker } from "@/components/voice/device-picker"
|
||||||
|
import { useVoiceState } from "@/hooks/use-voice-state"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
import { getInitials } from "@/lib/utils"
|
import { getInitials } from "@/lib/utils"
|
||||||
import type { SidebarUser } from "@/lib/auth"
|
import type { SidebarUser } from "@/lib/auth"
|
||||||
|
|
||||||
|
function stopEvent(e: React.MouseEvent | React.PointerEvent): void {
|
||||||
|
e.stopPropagation()
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
export function NavUser({
|
export function NavUser({
|
||||||
user,
|
user,
|
||||||
}: {
|
}: {
|
||||||
readonly user: SidebarUser | null
|
readonly user: SidebarUser | null
|
||||||
}) {
|
}): React.ReactElement | null {
|
||||||
const { isMobile } = useSidebar()
|
const { isMobile } = useSidebar()
|
||||||
const [accountOpen, setAccountOpen] = React.useState(false)
|
const [accountOpen, setAccountOpen] = React.useState(false)
|
||||||
|
const {
|
||||||
|
isMuted,
|
||||||
|
isDeafened,
|
||||||
|
inputDeviceId,
|
||||||
|
outputDeviceId,
|
||||||
|
inputDevices,
|
||||||
|
outputDevices,
|
||||||
|
toggleMute,
|
||||||
|
toggleDeafen,
|
||||||
|
setInputDevice,
|
||||||
|
setOutputDevice,
|
||||||
|
} = useVoiceState()
|
||||||
|
|
||||||
// Don't render if no user (shouldn't happen in authenticated routes)
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const initials = getInitials(user.name)
|
const initials = getInitials(user.name)
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout(): Promise<void> {
|
||||||
await logout()
|
await logout()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,17 +93,51 @@ export function NavUser({
|
|||||||
size="lg"
|
size="lg"
|
||||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||||
>
|
>
|
||||||
<Avatar className="h-8 w-8 rounded-lg grayscale">
|
<Avatar className="h-8 w-8 shrink-0 rounded-lg grayscale">
|
||||||
{user.avatar && <AvatarImage src={user.avatar} alt={user.name} />}
|
{user.avatar && (
|
||||||
<AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
|
<AvatarImage src={user.avatar} alt={user.name} />
|
||||||
|
)}
|
||||||
|
<AvatarFallback className="rounded-lg">
|
||||||
|
{initials}
|
||||||
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<span className="min-w-0 flex-1 truncate text-sm font-medium text-sidebar-foreground">
|
||||||
<span className="text-sidebar-foreground truncate font-medium">{user.name}</span>
|
{user.name}
|
||||||
<span className="text-sidebar-foreground/70 truncate text-xs">
|
|
||||||
{user.email}
|
|
||||||
</span>
|
</span>
|
||||||
|
{/* Voice controls -- replace the old dots icon */}
|
||||||
|
<div className="group-data-[collapsible=icon]:hidden flex shrink-0 items-center">
|
||||||
|
<DeviceButtonGroup
|
||||||
|
isMuted={isMuted}
|
||||||
|
onToggle={(e) => { stopEvent(e); toggleMute() }}
|
||||||
|
icon={isMuted ? IconMicrophoneOff : IconMicrophone}
|
||||||
|
label={isMuted ? "Unmute" : "Mute"}
|
||||||
|
dimmed={isMuted}
|
||||||
|
devices={inputDevices}
|
||||||
|
selectedDeviceId={inputDeviceId}
|
||||||
|
onSelectDevice={setInputDevice}
|
||||||
|
deviceLabel="Input Device"
|
||||||
|
/>
|
||||||
|
<DeviceButtonGroup
|
||||||
|
isMuted={isDeafened}
|
||||||
|
onToggle={(e) => { stopEvent(e); toggleDeafen() }}
|
||||||
|
icon={isDeafened ? IconHeadphonesOff : IconHeadphones}
|
||||||
|
label={isDeafened ? "Undeafen" : "Deafen"}
|
||||||
|
dimmed={isDeafened}
|
||||||
|
devices={outputDevices}
|
||||||
|
selectedDeviceId={outputDeviceId}
|
||||||
|
onSelectDevice={setOutputDevice}
|
||||||
|
deviceLabel="Output Device"
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/settings"
|
||||||
|
onClick={stopEvent}
|
||||||
|
onPointerDown={stopEvent}
|
||||||
|
aria-label="Settings"
|
||||||
|
className="ml-px flex size-5 items-center justify-center rounded-sm text-sidebar-foreground/60 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||||
|
>
|
||||||
|
<IconSettings className="size-3" />
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<IconDotsVertical className="text-sidebar-foreground/70 ml-auto size-4" />
|
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent
|
<DropdownMenuContent
|
||||||
@ -85,12 +149,16 @@ export function NavUser({
|
|||||||
<DropdownMenuLabel className="p-0 font-normal">
|
<DropdownMenuLabel className="p-0 font-normal">
|
||||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||||
<Avatar className="h-8 w-8 rounded-lg">
|
<Avatar className="h-8 w-8 rounded-lg">
|
||||||
{user.avatar && <AvatarImage src={user.avatar} alt={user.name} />}
|
{user.avatar && (
|
||||||
<AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
|
<AvatarImage src={user.avatar} alt={user.name} />
|
||||||
|
)}
|
||||||
|
<AvatarFallback className="rounded-lg">
|
||||||
|
{initials}
|
||||||
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||||
<span className="truncate font-medium">{user.name}</span>
|
<span className="truncate font-medium">{user.name}</span>
|
||||||
<span className="text-muted-foreground truncate text-xs">
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
{user.email}
|
{user.email}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@ -119,7 +187,76 @@ export function NavUser({
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
<AccountModal open={accountOpen} onOpenChange={setAccountOpen} user={user} />
|
<AccountModal
|
||||||
|
open={accountOpen}
|
||||||
|
onOpenChange={setAccountOpen}
|
||||||
|
user={user}
|
||||||
|
/>
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tight icon+chevron pair: toggle button and device picker
|
||||||
|
* as one visual group to save horizontal space.
|
||||||
|
*/
|
||||||
|
function DeviceButtonGroup({
|
||||||
|
onToggle,
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
dimmed,
|
||||||
|
devices,
|
||||||
|
selectedDeviceId,
|
||||||
|
onSelectDevice,
|
||||||
|
deviceLabel,
|
||||||
|
}: {
|
||||||
|
readonly isMuted: boolean
|
||||||
|
readonly onToggle: (e: React.MouseEvent) => void
|
||||||
|
readonly icon: React.ComponentType<{ className?: string }>
|
||||||
|
readonly label: string
|
||||||
|
readonly dimmed: boolean
|
||||||
|
readonly devices: MediaDeviceInfo[]
|
||||||
|
readonly selectedDeviceId: string | undefined
|
||||||
|
readonly onSelectDevice: (deviceId: string) => void
|
||||||
|
readonly deviceLabel: string
|
||||||
|
}): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggle}
|
||||||
|
onPointerDown={stopEvent}
|
||||||
|
aria-label={label}
|
||||||
|
className={cn(
|
||||||
|
"flex size-5 items-center justify-center rounded-sm transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground",
|
||||||
|
dimmed
|
||||||
|
? "text-sidebar-foreground/40"
|
||||||
|
: "text-sidebar-foreground/60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-3" />
|
||||||
|
</button>
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={stopEvent}
|
||||||
|
onPointerDown={stopEvent}
|
||||||
|
aria-label={`Select ${deviceLabel.toLowerCase()}`}
|
||||||
|
className="flex h-5 w-3 items-center justify-center rounded-sm text-sidebar-foreground/40 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||||
|
>
|
||||||
|
<IconChevronUp className="size-2.5" />
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent side="top" align="start" className="w-64 p-0">
|
||||||
|
<DevicePicker
|
||||||
|
devices={devices}
|
||||||
|
selectedDeviceId={selectedDeviceId}
|
||||||
|
onSelectDevice={onSelectDevice}
|
||||||
|
label={deviceLabel}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
60
src/components/voice/device-picker.tsx
Normal file
60
src/components/voice/device-picker.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { IconCheck } from "@tabler/icons-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
type DevicePickerProps = {
|
||||||
|
readonly devices: MediaDeviceInfo[]
|
||||||
|
readonly selectedDeviceId: string | undefined
|
||||||
|
readonly onSelectDevice: (deviceId: string) => void
|
||||||
|
readonly label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanDeviceLabel(label: string): string {
|
||||||
|
return label.replace(/\(([\da-fA-F]{4}:[\da-fA-F]{4})\)$/, "").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DevicePicker({
|
||||||
|
devices,
|
||||||
|
selectedDeviceId,
|
||||||
|
onSelectDevice,
|
||||||
|
label,
|
||||||
|
}: DevicePickerProps): React.ReactElement {
|
||||||
|
return (
|
||||||
|
<div className="p-2">
|
||||||
|
<div className="mb-2 px-2 text-xs font-medium text-muted-foreground">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
{devices.length === 0 ? (
|
||||||
|
<div className="px-2 py-4 text-center text-sm text-muted-foreground">
|
||||||
|
No devices found
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{devices.map((device) => {
|
||||||
|
const isSelected = device.deviceId === selectedDeviceId
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={device.deviceId}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectDevice(device.deviceId)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-accent",
|
||||||
|
isSelected && "bg-accent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex size-4 shrink-0 items-center justify-center">
|
||||||
|
{isSelected && <IconCheck className="size-3.5" />}
|
||||||
|
</div>
|
||||||
|
<span className="flex-1 truncate text-left">
|
||||||
|
{cleanDeviceLabel(device.label) || "Unknown Device"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
140
src/components/voice/voice-panel.tsx
Normal file
140
src/components/voice/voice-panel.tsx
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
IconAntenna,
|
||||||
|
IconPhoneOff,
|
||||||
|
IconScreenShare,
|
||||||
|
IconScreenShareOff,
|
||||||
|
IconVideo,
|
||||||
|
IconVideoOff,
|
||||||
|
IconWaveSine,
|
||||||
|
IconSparkles,
|
||||||
|
} from "@tabler/icons-react"
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipTrigger,
|
||||||
|
TooltipContent,
|
||||||
|
} from "@/components/ui/tooltip"
|
||||||
|
import { useVoiceState } from "@/hooks/use-voice-state"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
export function VoicePanel(): React.ReactElement {
|
||||||
|
const {
|
||||||
|
channelName,
|
||||||
|
isScreenSharing,
|
||||||
|
isCameraOn,
|
||||||
|
isNoiseSuppression,
|
||||||
|
toggleScreenShare,
|
||||||
|
toggleCamera,
|
||||||
|
toggleNoiseSuppression,
|
||||||
|
leaveChannel,
|
||||||
|
} = useVoiceState()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group-data-[collapsible=icon]:hidden border-t border-sidebar-border">
|
||||||
|
{/* Connection status and disconnect */}
|
||||||
|
<div className="p-2">
|
||||||
|
<div className="mb-1 flex items-center gap-1.5 text-xs text-emerald-500">
|
||||||
|
<IconAntenna className="size-3.5" />
|
||||||
|
<span className="font-medium">Voice Connected</span>
|
||||||
|
</div>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<span className="truncate">#{channelName}</span>
|
||||||
|
<span>2👤</span>
|
||||||
|
</div>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={leaveChannel}
|
||||||
|
className="flex size-6 items-center justify-center rounded-md text-muted-foreground hover:bg-sidebar-accent hover:text-foreground"
|
||||||
|
aria-label="Disconnect"
|
||||||
|
>
|
||||||
|
<IconPhoneOff className="size-4" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Disconnect</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toggle controls row */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleScreenShare}
|
||||||
|
className={cn(
|
||||||
|
"flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-foreground",
|
||||||
|
isScreenSharing && "bg-sidebar-accent text-foreground"
|
||||||
|
)}
|
||||||
|
aria-label="Toggle screen share"
|
||||||
|
>
|
||||||
|
{isScreenSharing ? (
|
||||||
|
<IconScreenShare className="size-4" />
|
||||||
|
) : (
|
||||||
|
<IconScreenShareOff className="size-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Screen Share</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleCamera}
|
||||||
|
className={cn(
|
||||||
|
"flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-foreground",
|
||||||
|
isCameraOn && "bg-sidebar-accent text-foreground"
|
||||||
|
)}
|
||||||
|
aria-label="Toggle camera"
|
||||||
|
>
|
||||||
|
{isCameraOn ? (
|
||||||
|
<IconVideo className="size-4" />
|
||||||
|
) : (
|
||||||
|
<IconVideoOff className="size-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Camera</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleNoiseSuppression}
|
||||||
|
className={cn(
|
||||||
|
"flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-sidebar-accent hover:text-foreground",
|
||||||
|
isNoiseSuppression && "bg-sidebar-accent text-foreground"
|
||||||
|
)}
|
||||||
|
aria-label="Toggle noise suppression"
|
||||||
|
>
|
||||||
|
<IconWaveSine className="size-4" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Noise Suppression</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
|
className="flex size-7 items-center justify-center rounded-md text-muted-foreground opacity-50"
|
||||||
|
aria-label="Activities (coming soon)"
|
||||||
|
>
|
||||||
|
<IconSparkles className="size-4" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Activities (coming soon)</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
10
src/components/voice/voice-provider.tsx
Normal file
10
src/components/voice/voice-provider.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import type { ReactNode } from "react"
|
||||||
|
import { VoiceContext, useVoiceStateLogic } from "@/hooks/use-voice-state"
|
||||||
|
|
||||||
|
export function VoiceProvider({ children }: { readonly children: ReactNode }) {
|
||||||
|
const voiceState = useVoiceStateLogic()
|
||||||
|
|
||||||
|
return <VoiceContext.Provider value={voiceState}>{children}</VoiceContext.Provider>
|
||||||
|
}
|
||||||
187
src/hooks/use-voice-state.ts
Normal file
187
src/hooks/use-voice-state.ts
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { createContext, useCallback, useContext, useEffect, useState } from "react"
|
||||||
|
|
||||||
|
type VoiceState = {
|
||||||
|
// Connection
|
||||||
|
channelId: string | null
|
||||||
|
channelName: string
|
||||||
|
// Toggles
|
||||||
|
isMuted: boolean
|
||||||
|
isDeafened: boolean
|
||||||
|
isScreenSharing: boolean
|
||||||
|
isCameraOn: boolean
|
||||||
|
isNoiseSuppression: boolean
|
||||||
|
// Device selection
|
||||||
|
inputDeviceId: string | undefined
|
||||||
|
outputDeviceId: string | undefined
|
||||||
|
// Device lists (from enumerateDevices)
|
||||||
|
inputDevices: MediaDeviceInfo[]
|
||||||
|
outputDevices: MediaDeviceInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type VoiceActions = {
|
||||||
|
toggleMute: () => void
|
||||||
|
toggleDeafen: () => void
|
||||||
|
toggleScreenShare: () => void
|
||||||
|
toggleCamera: () => void
|
||||||
|
toggleNoiseSuppression: () => void
|
||||||
|
setInputDevice: (deviceId: string) => void
|
||||||
|
setOutputDevice: (deviceId: string) => void
|
||||||
|
joinChannel: (id: string, name: string) => void
|
||||||
|
leaveChannel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VoiceContextValue = VoiceState & VoiceActions
|
||||||
|
|
||||||
|
export const VoiceContext = createContext<VoiceContextValue | null>(null)
|
||||||
|
|
||||||
|
export function useVoiceState(): VoiceContextValue {
|
||||||
|
const context = useContext(VoiceContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useVoiceState must be used within VoiceProvider")
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useVoiceStateLogic(): VoiceContextValue {
|
||||||
|
// Connection state
|
||||||
|
const [channelId, setChannelId] = useState<string | null>(null)
|
||||||
|
const [channelName, setChannelName] = useState<string>("")
|
||||||
|
|
||||||
|
// Toggle state
|
||||||
|
const [isMuted, setIsMuted] = useState<boolean>(false)
|
||||||
|
const [isDeafened, setIsDeafened] = useState<boolean>(false)
|
||||||
|
const [isScreenSharing, setIsScreenSharing] = useState<boolean>(false)
|
||||||
|
const [isCameraOn, setIsCameraOn] = useState<boolean>(false)
|
||||||
|
const [isNoiseSuppression, setIsNoiseSuppression] = useState<boolean>(true)
|
||||||
|
|
||||||
|
// Device state
|
||||||
|
const [inputDeviceId, setInputDeviceId] = useState<string | undefined>(undefined)
|
||||||
|
const [outputDeviceId, setOutputDeviceId] = useState<string | undefined>(undefined)
|
||||||
|
const [inputDevices, setInputDevices] = useState<MediaDeviceInfo[]>([])
|
||||||
|
const [outputDevices, setOutputDevices] = useState<MediaDeviceInfo[]>([])
|
||||||
|
|
||||||
|
// Load devices
|
||||||
|
const loadDevices = useCallback(async (): Promise<void> => {
|
||||||
|
if (typeof navigator === "undefined" || !navigator.mediaDevices?.enumerateDevices) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||||
|
const inputs = devices.filter((device) => device.kind === "audioinput")
|
||||||
|
const outputs = devices.filter((device) => device.kind === "audiooutput")
|
||||||
|
|
||||||
|
setInputDevices(inputs)
|
||||||
|
setOutputDevices(outputs)
|
||||||
|
|
||||||
|
// Set defaults if not already set
|
||||||
|
if (!inputDeviceId && inputs.length > 0) {
|
||||||
|
setInputDeviceId(inputs[0].deviceId)
|
||||||
|
}
|
||||||
|
if (!outputDeviceId && outputs.length > 0) {
|
||||||
|
setOutputDeviceId(outputs[0].deviceId)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to enumerate devices:", error)
|
||||||
|
}
|
||||||
|
}, [inputDeviceId, outputDeviceId])
|
||||||
|
|
||||||
|
// Listen for device changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof navigator === "undefined" || !navigator.mediaDevices) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loadDevices()
|
||||||
|
|
||||||
|
const handleDeviceChange = (): void => {
|
||||||
|
loadDevices()
|
||||||
|
}
|
||||||
|
|
||||||
|
navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange)
|
||||||
|
return () => {
|
||||||
|
navigator.mediaDevices.removeEventListener("devicechange", handleDeviceChange)
|
||||||
|
}
|
||||||
|
}, [loadDevices])
|
||||||
|
|
||||||
|
// Toggle functions
|
||||||
|
const toggleMute = useCallback((): void => {
|
||||||
|
if (isDeafened) {
|
||||||
|
// Un-deafen and un-mute
|
||||||
|
setIsDeafened(false)
|
||||||
|
setIsMuted(false)
|
||||||
|
} else {
|
||||||
|
setIsMuted((prev) => !prev)
|
||||||
|
}
|
||||||
|
}, [isDeafened])
|
||||||
|
|
||||||
|
const toggleDeafen = useCallback((): void => {
|
||||||
|
setIsDeafened((prev) => {
|
||||||
|
const newDeafened = !prev
|
||||||
|
if (newDeafened) {
|
||||||
|
// Auto-mute when deafening
|
||||||
|
setIsMuted(true)
|
||||||
|
}
|
||||||
|
return newDeafened
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleScreenShare = useCallback((): void => {
|
||||||
|
setIsScreenSharing((prev) => !prev)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleCamera = useCallback((): void => {
|
||||||
|
setIsCameraOn((prev) => !prev)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const toggleNoiseSuppression = useCallback((): void => {
|
||||||
|
setIsNoiseSuppression((prev) => !prev)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Device setters
|
||||||
|
const setInputDevice = useCallback((deviceId: string): void => {
|
||||||
|
setInputDeviceId(deviceId)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const setOutputDevice = useCallback((deviceId: string): void => {
|
||||||
|
setOutputDeviceId(deviceId)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Channel management
|
||||||
|
const joinChannel = useCallback((id: string, name: string): void => {
|
||||||
|
setChannelId(id)
|
||||||
|
setChannelName(name)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const leaveChannel = useCallback((): void => {
|
||||||
|
setChannelId(null)
|
||||||
|
setChannelName("")
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
// State
|
||||||
|
channelId,
|
||||||
|
channelName,
|
||||||
|
isMuted,
|
||||||
|
isDeafened,
|
||||||
|
isScreenSharing,
|
||||||
|
isCameraOn,
|
||||||
|
isNoiseSuppression,
|
||||||
|
inputDeviceId,
|
||||||
|
outputDeviceId,
|
||||||
|
inputDevices,
|
||||||
|
outputDevices,
|
||||||
|
// Actions
|
||||||
|
toggleMute,
|
||||||
|
toggleDeafen,
|
||||||
|
toggleScreenShare,
|
||||||
|
toggleCamera,
|
||||||
|
toggleNoiseSuppression,
|
||||||
|
setInputDevice,
|
||||||
|
setOutputDevice,
|
||||||
|
joinChannel,
|
||||||
|
leaveChannel,
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user