- {user.avatar && }
- {initials}
+ {user.avatar && (
+
+ )}
+
+ {initials}
+
{user.name}
-
+
{user.email}
@@ -119,7 +187,76 @@ export function NavUser({
-
+
)
}
+
+/**
+ * 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 (
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/voice/device-picker.tsx b/src/components/voice/device-picker.tsx
new file mode 100644
index 0000000..1989c23
--- /dev/null
+++ b/src/components/voice/device-picker.tsx
@@ -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 (
+
+
+ {label}
+
+ {devices.length === 0 ? (
+
+ No devices found
+
+ ) : (
+
+ {devices.map((device) => {
+ const isSelected = device.deviceId === selectedDeviceId
+ return (
+
+ )
+ })}
+
+ )}
+
+ )
+}
diff --git a/src/components/voice/voice-panel.tsx b/src/components/voice/voice-panel.tsx
new file mode 100644
index 0000000..0bfdf0a
--- /dev/null
+++ b/src/components/voice/voice-panel.tsx
@@ -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 (
+
+ {/* Connection status and disconnect */}
+
+
+
+ Voice Connected
+
+
+
+ #{channelName}
+ 2👤
+
+
+
+
+
+ Disconnect
+
+
+
+ {/* Toggle controls row */}
+
+
+
+
+
+ Screen Share
+
+
+
+
+
+
+ Camera
+
+
+
+
+
+
+ Noise Suppression
+
+
+
+
+
+
+ Activities (coming soon)
+
+
+
+
+ )
+}
diff --git a/src/components/voice/voice-provider.tsx b/src/components/voice/voice-provider.tsx
new file mode 100644
index 0000000..7ca6789
--- /dev/null
+++ b/src/components/voice/voice-provider.tsx
@@ -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
{children}
+}
diff --git a/src/hooks/use-voice-state.ts b/src/hooks/use-voice-state.ts
new file mode 100644
index 0000000..cf98835
--- /dev/null
+++ b/src/hooks/use-voice-state.ts
@@ -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
(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(null)
+ const [channelName, setChannelName] = useState("")
+
+ // Toggle state
+ const [isMuted, setIsMuted] = useState(false)
+ const [isDeafened, setIsDeafened] = useState(false)
+ const [isScreenSharing, setIsScreenSharing] = useState(false)
+ const [isCameraOn, setIsCameraOn] = useState(false)
+ const [isNoiseSuppression, setIsNoiseSuppression] = useState(true)
+
+ // Device state
+ const [inputDeviceId, setInputDeviceId] = useState(undefined)
+ const [outputDeviceId, setOutputDeviceId] = useState(undefined)
+ const [inputDevices, setInputDevices] = useState([])
+ const [outputDevices, setOutputDevices] = useState([])
+
+ // Load devices
+ const loadDevices = useCallback(async (): Promise => {
+ 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,
+ }
+}