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 { DesktopShell } from "@/components/desktop/desktop-shell"
|
||||
import { DesktopOfflineBanner } from "@/components/desktop/offline-banner"
|
||||
import { VoiceProvider } from "@/components/voice/voice-provider"
|
||||
|
||||
export default async function DashboardLayout({
|
||||
children,
|
||||
@ -45,6 +46,7 @@ export default async function DashboardLayout({
|
||||
|
||||
return (
|
||||
<ChatProvider>
|
||||
<VoiceProvider>
|
||||
<SettingsProvider>
|
||||
<ProjectListProvider projects={projectList}>
|
||||
<PageActionsProvider>
|
||||
@ -90,6 +92,7 @@ export default async function DashboardLayout({
|
||||
</PageActionsProvider>
|
||||
</ProjectListProvider>
|
||||
</SettingsProvider>
|
||||
</VoiceProvider>
|
||||
</ChatProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@ -19,8 +19,10 @@ import { NavFiles } from "@/components/nav-files"
|
||||
import { NavProjects } from "@/components/nav-projects"
|
||||
import { NavConversations } from "@/components/nav-conversations"
|
||||
import { NavUser } from "@/components/nav-user"
|
||||
import { VoicePanel } from "@/components/voice/voice-panel"
|
||||
// settings is now a page at /dashboard/settings
|
||||
import { openFeedbackDialog } from "@/components/feedback-widget"
|
||||
import { useVoiceState } from "@/hooks/use-voice-state"
|
||||
import type { SidebarUser } from "@/lib/auth"
|
||||
import {
|
||||
Sidebar,
|
||||
@ -148,6 +150,7 @@ export function AppSidebar({
|
||||
readonly user: SidebarUser | null
|
||||
}) {
|
||||
const { isMobile } = useSidebar()
|
||||
const { channelId } = useVoiceState()
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" {...props}>
|
||||
@ -198,6 +201,7 @@ export function AppSidebar({
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)}
|
||||
{channelId !== null && <VoicePanel />}
|
||||
<NavUser user={user} />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
|
||||
@ -1,25 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Volume2 } from "lucide-react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { SidebarMenuButton } from "@/components/ui/sidebar"
|
||||
import { useVoiceState } from "@/hooks/use-voice-state"
|
||||
|
||||
type VoiceChannelStubProps = {
|
||||
readonly id: 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 (
|
||||
<SidebarMenuButton
|
||||
className="cursor-not-allowed opacity-60"
|
||||
disabled
|
||||
tooltip={`${name} (Coming Soon)`}
|
||||
onClick={handleClick}
|
||||
isActive={isActive}
|
||||
tooltip={name}
|
||||
>
|
||||
<Volume2 className="h-4 w-4" />
|
||||
<span>{name}</span>
|
||||
<Badge variant="secondary" className="ml-auto text-[10px]">
|
||||
Soon
|
||||
</Badge>
|
||||
</SidebarMenuButton>
|
||||
)
|
||||
}
|
||||
|
||||
@ -279,7 +279,7 @@ export function NavConversations() {
|
||||
<SidebarMenu>
|
||||
{voiceChannels.map((channel) => (
|
||||
<SidebarMenuItem key={channel.id}>
|
||||
<VoiceChannelStub name={channel.name} />
|
||||
<VoiceChannelStub id={channel.id} name={channel.name} />
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
|
||||
@ -1,12 +1,18 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import Link from "next/link"
|
||||
import {
|
||||
IconCreditCard,
|
||||
IconDotsVertical,
|
||||
IconLogout,
|
||||
IconNotification,
|
||||
IconUserCircle,
|
||||
IconMicrophone,
|
||||
IconMicrophoneOff,
|
||||
IconHeadphones,
|
||||
IconHeadphonesOff,
|
||||
IconSettings,
|
||||
IconChevronUp,
|
||||
} from "@tabler/icons-react"
|
||||
|
||||
import { logout } from "@/app/actions/profile"
|
||||
@ -31,26 +37,50 @@ import {
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
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 type { SidebarUser } from "@/lib/auth"
|
||||
|
||||
function stopEvent(e: React.MouseEvent | React.PointerEvent): void {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
export function NavUser({
|
||||
user,
|
||||
}: {
|
||||
readonly user: SidebarUser | null
|
||||
}) {
|
||||
}): React.ReactElement | null {
|
||||
const { isMobile } = useSidebar()
|
||||
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) {
|
||||
return null
|
||||
}
|
||||
|
||||
const initials = getInitials(user.name)
|
||||
|
||||
async function handleLogout() {
|
||||
async function handleLogout(): Promise<void> {
|
||||
await logout()
|
||||
}
|
||||
|
||||
@ -63,17 +93,51 @@ export function NavUser({
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<Avatar className="h-8 w-8 rounded-lg grayscale">
|
||||
{user.avatar && <AvatarImage src={user.avatar} alt={user.name} />}
|
||||
<AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
|
||||
<Avatar className="h-8 w-8 shrink-0 rounded-lg grayscale">
|
||||
{user.avatar && (
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
)}
|
||||
<AvatarFallback className="rounded-lg">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="text-sidebar-foreground truncate font-medium">{user.name}</span>
|
||||
<span className="text-sidebar-foreground/70 truncate text-xs">
|
||||
{user.email}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium text-sidebar-foreground">
|
||||
{user.name}
|
||||
</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>
|
||||
<IconDotsVertical className="text-sidebar-foreground/70 ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
@ -85,12 +149,16 @@ export function NavUser({
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar className="h-8 w-8 rounded-lg">
|
||||
{user.avatar && <AvatarImage src={user.avatar} alt={user.name} />}
|
||||
<AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
|
||||
{user.avatar && (
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
)}
|
||||
<AvatarFallback className="rounded-lg">
|
||||
{initials}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<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}
|
||||
</span>
|
||||
</div>
|
||||
@ -119,7 +187,76 @@ export function NavUser({
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
<AccountModal open={accountOpen} onOpenChange={setAccountOpen} user={user} />
|
||||
<AccountModal
|
||||
open={accountOpen}
|
||||
onOpenChange={setAccountOpen}
|
||||
user={user}
|
||||
/>
|
||||
</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