import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import { sendEncryptedMessage } from '@chat-app/shared/chat';
import {
  type CallKind,
  type CallSignal,
  fetchLivekitToken,
  signalTopic,
} from '@chat-app/shared/rtc';
import type { RealtimeChannel } from '@supabase/supabase-js';
import {
  ConnectionQuality,
  ConnectionState,
  type Participant,
  type RemoteParticipant,
  type RemoteTrack,
  type RemoteTrackPublication,
  Room,
  RoomEvent,
  Track,
} from 'livekit-client';
import {
  createContext,
  type ReactNode,
  useCallback,
  useContext,
  useEffect,
  useMemo,
  useRef,
  useState,
} from 'react';

import { useAuth } from './AuthContext';
import { useConversationsContext } from './ConversationsContext';
import {
  playDeafenBeep,
  playEndBeep,
  playJoinBeep,
  playLeaveBeep,
  playMuteBeep,
  playUndeafenBeep,
  playUnmuteBeep,
} from '../lib/callSounds';
import { useLiveCaptions } from '../lib/useLiveCaptions';
import { setCallWakeLock } from '../lib/wakeLock';
import { notify } from '../lib/osNotify';
import {
  isTauriRuntime,
  registerGlobalShortcutPress,
  registerPttShortcut,
  unregisterGlobalShortcut,
  unregisterPttShortcut,
} from '../lib/globalShortcut';
import { getPttSettings, subscribePttSettings } from '../lib/pttSettings';
import {
  bindingToTauriShortcut,
  eventMatchesBinding,
  getVoiceHotkeys,
  subscribeVoiceHotkeys,
  type VoiceHotkeys,
} from '../lib/voiceHotkeys';
import {
  getAudioQualityParams,
  getAudioSettings,
  subscribeAudioSettings,
  updateAudioSettings,
} from '../lib/audioSettings';
import {
  applyBackgroundBlurToLocal,
  removeBackgroundBlurFromLocal,
} from '../lib/videoBlur';
import {
  createCallE2EE,
  getCallE2EESettings,
  isE2EESupported,
} from '../lib/callE2EE';
import { createMicPipeline, type MicPipeline } from '../lib/micPipeline';
import {
  getParticipantVolume,
  subscribeParticipantVolumes,
} from '../lib/participantVolumes';
import {
  allPipelines,
  createPipeline,
  destroyPipeline,
  type RemoteAudioPipeline,
  setAllPipelinesSinkId,
  setPipelineGain,
} from '../lib/remoteAudioPipelines';
import {
  type LoopbackTrackHandle,
  startLoopbackTrack,
} from '../lib/loopbackAudio';
import {
  type SystemAudioHandle,
  startSystemAudioCapture,
} from '../lib/screenAudio';
import {
  clearScreenShareVolumes,
  getScreenShareVolume,
  subscribeScreenShareVolumes,
} from '../lib/screenShareVolumes';
import { startSoundboardHotkeys } from '../lib/soundboardHotkeys';
import { playEntry } from '../lib/soundboardPlayback';
import {
  getPrefs as getSoundboardPrefs,
  listSounds as listSoundboard,
  updatePrefs as updateSoundboardPrefs,
} from '../lib/soundboardStorage';
import {
  type DisplaySurfaceHint,
  getPresetParams,
  getScreenShareSettings,
  subscribeScreenShareSettings,
  type ScreenSharePreset,
  updateScreenShareSettings,
} from '../lib/screenShareSettings';
import { devLocalSecretStore } from '../lib/secretStore';
import { supabase } from '../lib/supabase';
import { setWindowFullscreen } from '../lib/windowFullscreen';

export type CallState =
  | { kind: 'idle' }
  | {
      kind: 'outgoing';
      callId: string;
      conversationId: string;
      mediaKind: CallKind;
      ringingSince: string;
    }
  | {
      kind: 'incoming';
      callId: string;
      conversationId: string;
      fromUserId: string;
      mediaKind: CallKind;
    }
  | {
      kind: 'connecting';
      callId: string;
      conversationId: string;
      mediaKind: CallKind;
    }
  | {
      kind: 'connected';
      callId: string;
      conversationId: string;
      mediaKind: CallKind;
      startedAt: string;
    }
  | {
      // LiveKit dropped the signaling socket but is actively retrying. The
      // room + tracks stay alive — the user's mic + speakers keep working —
      // they just can't reach peers until we're back. Distinct from
      // `connecting` so the UI can show "Verbinde neu…" vs "Verbinde…".
      kind: 'reconnecting';
      callId: string;
      conversationId: string;
      mediaKind: CallKind;
      startedAt: string;
    }
  | { kind: 'error'; message: string };

export interface RemoteScreenShare {
  track: RemoteTrack;
  participantId: string;
  participantName: string;
}

// Discord-style "second-call" ringer — a peer invites us while we're
// already in another call. Lives alongside CallState so the active call
// keeps running while we decide. Only one slot: a third caller arriving
// while this is set still gets an automatic busy-reject.
export interface PendingIncoming {
  callId: string;
  conversationId: string;
  fromUserId: string;
  mediaKind: CallKind;
}

// Visual call modes (Discord-style): grid shows all tiles equally, focus pins
// one speaker with others in a strip, fullscreen is cinema mode.
export type CallMode = 'grid' | 'focus' | 'fullscreen';

interface CallContextValue {
  state: CallState;
  room: Room | null;
  remoteParticipants: RemoteParticipant[];
  isMuted: boolean;
  isE2EEActive: boolean;
  isScreenSharing: boolean;
  isCameraEnabled: boolean;
  isDeafened: boolean;
  /** Participants whose screen share the local user has actively clicked
   *  "Bildschirm anschauen" on. Session-only (cleared on call end). Used to
   *  gate both the <video> rendering and the ScreenShareAudio playback so
   *  sound only plays after an explicit opt-in. */
  watchingShareUserIds: ReadonlySet<string>;
  /** Participants whose share has been right-click dismissed ("Zuschauen
   *  beenden"). Filters their screen-tile out of the grid until they stop
   *  + restart sharing (track-unsubscribe clears the entry). */
  dismissedShareUserIds: ReadonlySet<string>;
  /** identity -> their deafen state, received via data channel. */
  remoteDeafen: Record<string, boolean>;
  /** identity -> LiveKit connectionQuality. Includes the local participant.
   *  Drives the Wifi-quality badge on each tile. */
  connectionQualities: Record<string, ConnectionQuality>;
  /** Discord-style host marker — the userId of whoever initiated the call.
   *  Set when starting an outgoing call (myId) or accepting an incoming
   *  invite (fromUserId). Cleared on disconnect. Drives the crown badge,
   *  but only in group calls. Null while idle or in 1:1 contexts. */
  callHostId: string | null;
  /** identity -> latest live-caption fragment received via data channel.
   *  Includes own captions for self-overlay. Receivers prune entries whose
   *  timestamp is older than ~5s so stale lines fade out. */
  captions: Record<string, { text: string; final: boolean; timestamp: number }>;
  /** Surface a caption for the local user — the live-captions hook calls
   *  this on every interim/final SpeechRecognition result so the overlay
   *  shows our own line without going through the SFU round-trip. */
  pushLocalCaption: (text: string, final: boolean) => void;
  /** identity -> mute state. Broadcast from peer whenever mic-gain flips.
   *  Needed because we can't rely on LiveKit's native isMicrophoneEnabled —
   *  the mic pipeline keeps the track published with sound flowing even
   *  while the mic path is gain-silenced, so LK never sees "muted". */
  remoteMute: Record<string, boolean>;
  // Zero or more remote screen shares (LiveKit supports multiple simultaneous).
  remoteScreenShares: RemoteScreenShare[];
  // Remembers the conversation of the last call we left so a sidebar widget
  // can show "still live — rejoin" while peers stay in the room.
  lastCallConversationId: string | null;
  // Clean-Rail UI state — display mode + focused participant id.
  callMode: CallMode;
  focusedId: string | null;
  // Actions:
  startCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>;
  joinActiveCall: (conversationId: string, mediaKind?: CallKind) => Promise<void>;
  acceptIncoming: (override?: CallKind) => Promise<void>;
  rejectIncoming: () => void;
  hangup: () => Promise<void>;
  toggleMute: () => void;
  toggleScreenShare: () => Promise<void>;
  startScreenShare: (
    overrides?: Partial<{
      preset: ScreenSharePreset;
      displaySurface: DisplaySurfaceHint;
      framerate: number | null;
      /** Source id from desktopCapturer (e.g. `window:<HWND>:0` or
       *  `screen:<id>:0`). When this is a window source AND we're on
       *  Windows with the native loopback addon, the system-audio
       *  capture switches to INCLUDE_TARGET_PROCESS_TREE so only the
       *  picked app's audio goes out (Discord parity). */
      pickedSourceId: string;
    }>,
  ) => Promise<void>;
  stopScreenShare: () => Promise<void>;
  toggleCamera: () => Promise<void>;
  toggleDeafen: () => void;
  dismissLastCall: () => void;
  setCallMode: (mode: CallMode) => void;
  setFocusedId: (id: string | null) => void;
  /** Play a soundboard entry through the active call's mic pipeline.
   *  No-op when not connected. Default single-fire per id (spamming the
   *  hotkey cuts the previous instance); set overlap=true to layer. */
  playSoundboard: (id: string, opts?: { overlap?: boolean }) => Promise<void>;
  /** Stop every active sb source, or just the one matching `id` if given. */
  stopSoundboard: (id?: string) => void;
  /** Ids of soundboard entries currently emitting audio. Updated live so
   *  the in-call panel can show a stop icon on active pads. */
  activeSoundboardIds: ReadonlySet<string>;
  /** Apply new sb master/monitor gains to the live pipeline. Persists via
   *  updatePrefs in the storage module. */
  setSoundboardMasterGain: (value: number) => Promise<void>;
  setSoundboardMonitorGain: (value: number) => Promise<void>;
  // Runtime mic switch. Persists choice so subsequent calls reuse it, and
  // hot-swaps the input on an active call without a reconnect.
  setAudioInputDevice: (deviceId: string | null) => Promise<void>;
  // Runtime speaker/headphone switch. Persists + applies setSinkId to all
  // currently-attached remote-audio elements.
  setAudioOutputDevice: (deviceId: string | null) => Promise<void>;
  // Runtime camera switch. Persists choice so subsequent publishes pick
  // it up; hot-swaps the active publication via LiveKit's switchActiveDevice.
  setVideoInputDevice: (deviceId: string | null) => Promise<void>;
  /** Non-fatal mic-setup error message (e.g. permission denied). Surfaced in
   *  the in-call panel as a retry-banner so the user can stay in the call and
   *  hear others while sorting out their mic. Null when the mic is working. */
  micError: string | null;
  clearMicError: () => void;
  /** Retry mic acquisition using the current audioSettings. Safe to call
   *  multiple times; no-op if there's no active room. */
  retryMic: () => Promise<void>;
  /** Flip a user's screen share into the watching state (video plays + audio
   *  unmutes). */
  watchShare: (userId: string) => void;
  /** Flip out of watching state (video pauses / placeholder + audio mutes).
   *  Does NOT dismiss the tile — use dismissShare to hide it entirely. */
  stopWatchingShare: (userId: string) => void;
  /** Remove a share tile from view for the rest of this session (or until
   *  the sharer stops + restarts). Also clears watching if applicable. */
  dismissShare: (userId: string) => void;
  /** Per-share-audio manual mute flag (in addition to the watching gate).
   *  When true, the ScreenShareAudio stays muted even when watching is on
   *  — lets the user watch the video without the audio track. */
  screenShareAudioMutedIds: ReadonlySet<string>;
  setScreenShareAudioMuted: (userId: string, muted: boolean) => void;
  /** Sender-side mute on our own outgoing ScreenShareAudio publication.
   *  When true, the LocalTrackPublication is muted at the LiveKit layer so
   *  peers receive silence without us having to stop + restart the share. */
  outgoingShareAudioMuted: boolean;
  toggleOutgoingShareAudioMute: () => Promise<void>;
  /** Discord-style second-call ringer. Set when an invite arrives while
   *  the user is already in a connected call. Shown as a top-center
   *  toast; accepting hangs up the active call cleanly and joins the new
   *  one. Null at all other times. */
  pendingIncoming: PendingIncoming | null;
  acceptPendingIncoming: () => Promise<void>;
  rejectPendingIncoming: () => void;
}

const CallContext = createContext<CallContextValue | null>(null);

const RING_TIMEOUT_MS = 45_000;
const SOLO_TIMEOUT_MS = 5 * 60_000;

function newCallId(): string {
  return crypto.randomUUID();
}

export function CallProvider({ children }: { children: ReactNode }) {
  const { session, device, profile } = useAuth();
  const { conversations } = useConversationsContext();
  const myId = session?.user.id;

  const [state, setState] = useState<CallState>({ kind: 'idle' });
  const [room, setRoom] = useState<Room | null>(null);
  const [remoteParticipants, setRemoteParticipants] = useState<RemoteParticipant[]>([]);
  const [isMuted, setIsMuted] = useState(false);
  const [isE2EEActive, setIsE2EEActive] = useState(false);
  const [isScreenSharing, setIsScreenSharing] = useState(false);
  const [isCameraEnabled, setIsCameraEnabled] = useState(false);
  const [isDeafened, setIsDeafened] = useState(false);
  const [remoteScreenShares, setRemoteScreenShares] = useState<RemoteScreenShare[]>([]);
  const [connectionQualities, setConnectionQualities] = useState<
    Record<string, ConnectionQuality>
  >({});
  const [callHostId, setCallHostId] = useState<string | null>(null);
  // Toggled when the loopback path is actively capturing system audio for a
  // share. Drives the auto-duck of remote call audio (see effective-gain
  // useEffect) so peers don't hear themselves echoed back when the OS-level
  // process-tree exclusion isn't watertight.
  const [isCapturingSystemAudio, setIsCapturingSystemAudio] = useState(false);
  const [captions, setCaptions] = useState<
    Record<string, { text: string; final: boolean; timestamp: number }>
  >({});
  const [lastCallConversationId, setLastCallConversationId] = useState<string | null>(null);
  const [callMode, setCallModeState] = useState<CallMode>('grid');
  const [focusedId, setFocusedIdState] = useState<string | null>(null);
  const [activeSoundboardIds, setActiveSoundboardIds] = useState<ReadonlySet<string>>(
    () => new Set<string>(),
  );
  const [micError, setMicError] = useState<string | null>(null);
  const [watchingShareUserIds, setWatchingShareUserIds] = useState<ReadonlySet<string>>(
    () => new Set<string>(),
  );
  const [dismissedShareUserIds, setDismissedShareUserIds] = useState<ReadonlySet<string>>(
    () => new Set<string>(),
  );
  const [screenShareAudioMutedIds, setScreenShareAudioMutedIds] = useState<
    ReadonlySet<string>
  >(() => new Set<string>());
  // Sender-side mute for our own outgoing ScreenShareAudio publication. Lets
  // the local sharer silence the system-audio half of their share without
  // tearing down the video. Reset whenever a fresh share starts.
  const [outgoingShareAudioMuted, setOutgoingShareAudioMutedState] = useState(false);
  // Second-call ringer state. The ref mirror is only needed inside event
  // callbacks (signal handler, presence sync) where the closure captured
  // the initial state value.
  const [pendingIncoming, setPendingIncomingState] = useState<PendingIncoming | null>(null);
  const pendingIncomingRef = useRef<PendingIncoming | null>(null);
  pendingIncomingRef.current = pendingIncoming;
  const pendingRingTimerRef = useRef<number | null>(null);

  const signalChannelRef = useRef<RealtimeChannel | null>(null);
  const presenceChannelRef = useRef<RealtimeChannel | null>(null);
  const ringTimerRef = useRef<number | null>(null);
  const soloTimerRef = useRef<number | null>(null);
  // Fallback for joinActiveCall: if a rejoin lands in an empty room (peers
  // left between "call still live" and our connect), force the transition
  // to `connected` after a few seconds so the UI doesn't hang in "Verbinde…"
  // indefinitely. The solo-timeout will then cleanly close if nobody arrives.
  const joinFallbackTimerRef = useRef<number | null>(null);
  // Handle for the Windows-only WASAPI system-audio capture. Lives in
  // lockstep with the ScreenShare video track published by
  // setScreenShareEnabled; teardown is chained to the video track's
  // `ended` event so stale audio can never outlive the video share.
  const nativeAudioCaptureRef = useRef<SystemAudioHandle | null>(null);
  // Handle for the napi-rs WASAPI process-loopback addon. Preferred on
  // Windows over Chromium's `audio: 'loopback'` source because the
  // addon excludes our own process tree — peers don't hear themselves
  // echoed back. Mutually exclusive with `nativeAudioCaptureRef`: if
  // the addon path fails or isn't available, the existing fallback
  // populates `nativeAudioCaptureRef` instead.
  const nativeLoopbackRef = useRef<LoopbackTrackHandle | null>(null);
  const roomRef = useRef<Room | null>(null);
  // Web Audio graph that mixes live mic + soundboard sources into a single
  // published track. Created per call in joinRoom, destroyed in disconnectRoom.
  const pipelineRef = useRef<MicPipeline | null>(null);
  // Tracks whether the current call was ever in the connected state — needed
  // so hangup/solo-timeout can emit a real duration message vs. "missed".
  const everConnectedRef = useRef<boolean>(false);
  // Peers we invited who haven't answered yet. The outgoing call only goes
  // `declined → idle` once *every* ringer has rejected (not just the first).
  // For 1:1 calls the set has one entry so behaviour is unchanged; for
  // groups, a single reject doesn't terminate the call while others ring.
  const pendingPeersRef = useRef<Set<string>>(new Set());
  // identity -> their current deafen state. Populated via LiveKit data
  // channel messages ({ type: 'presence', deafened: bool }). Exposed as
  // state so consumer components re-render on change.
  const [remoteDeafen, setRemoteDeafen] = useState<Record<string, boolean>>({});
  const [remoteMute, setRemoteMute] = useState<Record<string, boolean>>({});
  // Mirror of isMuted for use inside LiveKit-event callbacks that run outside
  // the React component (ParticipantConnected rebroadcast etc).
  const mutedRef = useRef<boolean>(false);
  // Remembers the pre-deafen mute state so toggling deafen off restores what
  // the user had before. Discord-style: deafen implies mute, and un-deafen
  // returns the user to whatever mute choice they had made pre-deafen.
  const preDeafenMutedRef = useRef<boolean | null>(null);
  const stateRef = useRef<CallState>(state);
  stateRef.current = state;
  // Keep latest conversations accessible from signal-channel closures without
  // re-subscribing the channel on every conversations update.
  const conversationsRef = useRef(conversations);
  conversationsRef.current = conversations;
  // Mirror own presence state for use inside signal-channel callbacks. DND
  // suppresses incoming-call OS notifications (ringtone is handled in CallUI
  // which has direct access to the auth profile).
  const presenceRef = useRef(profile?.presenceState ?? 'offline');
  presenceRef.current = profile?.presenceState ?? 'offline';

  // --- Helpers -----------------------------------------------------------

  // Emit a structured call-event message into the conversation so call
  // history shows up in the chat. Caller-side only (to dedupe).
  const emitCallEvent = useCallback(
    async (
      conversationId: string,
      status: 'ended' | 'missed' | 'declined',
      mediaKind: CallKind,
      durationSec: number,
    ) => {
      if (!session?.user.id || !device?.id) return;
      try {
        const priv = await loadDevicePrivateKey(
          devLocalSecretStore,
          session.user.id,
          device.id,
        );
        if (!priv) return;
        const payload = JSON.stringify({
          v: 1,
          type: 'call_event',
          status,
          mediaKind,
          durationSec,
        });
        await sendEncryptedMessage({
          client: supabase,
          conversationId,
          plaintext: payload,
          senderUserId: session.user.id,
          senderDeviceId: device.id,
          senderPrivateKey: priv,
        });
      } catch (err: unknown) {
        console.error('emitCallEvent failed', err);
      }
    },
    [session?.user.id, device?.id],
  );

  const clearRingTimer = useCallback(() => {
    if (ringTimerRef.current !== null) {
      window.clearTimeout(ringTimerRef.current);
      ringTimerRef.current = null;
    }
  }, []);

  const clearSoloTimer = useCallback(() => {
    if (soloTimerRef.current !== null) {
      window.clearTimeout(soloTimerRef.current);
      soloTimerRef.current = null;
    }
  }, []);

  const clearJoinFallbackTimer = useCallback(() => {
    if (joinFallbackTimerRef.current !== null) {
      window.clearTimeout(joinFallbackTimerRef.current);
      joinFallbackTimerRef.current = null;
    }
  }, []);

  // Extracted so retryMic can call it after the user grants permission from
  // OS settings. Reads audioSettings fresh every call so NS/input-device
  // flips take effect without rejoining the room.
  const setupMicPipeline = useCallback(async (r: Room): Promise<void> => {
    const audioPrefs = getAudioSettings();
    const aParams = getAudioQualityParams(audioPrefs.quality);
    const inputId = audioPrefs.inputDeviceId;
    const nsEffective = audioPrefs.noiseSuppression && aParams.noiseSuppression;
    try {
      const rawStream = await navigator.mediaDevices.getUserMedia({
        audio: {
          echoCancellation: aParams.echoCancellation,
          noiseSuppression: nsEffective,
          autoGainControl: aParams.autoGainControl,
          channelCount: aParams.stereo ? 2 : 1,
          sampleRate: aParams.sampleRateHz,
          ...(inputId ? { deviceId: { ideal: inputId } } : {}),
        },
        video: false,
      });
      const rawTrack = rawStream.getAudioTracks()[0];
      if (!rawTrack) throw new Error('no audio track from getUserMedia');
      // Retry path: dispose any prior pipeline so we don't leak contexts.
      const prev = pipelineRef.current;
      if (prev) {
        try {
          prev.destroy();
        } catch {
          /* ignore */
        }
        pipelineRef.current = null;
      }
      const pipeline = createMicPipeline(rawTrack);
      pipelineRef.current = pipeline;
      try {
        const prefs = await getSoundboardPrefs();
        pipeline.setSoundboardGain(prefs.masterGain);
        pipeline.setMonitorGain(prefs.monitorGain);
      } catch (err: unknown) {
        console.warn('getSoundboardPrefs failed', err);
      }
      await r.localParticipant.publishTrack(pipeline.outputTrack, {
        source: Track.Source.Microphone,
        red: true,
        dtx: aParams.stereo ? false : true,
        forceStereo: aParams.stereo,
      });
      setMicError(null);
    } catch (err: unknown) {
      // Categorise the error so the banner can be specific. DOMException
      // names are stable across Chrome/Firefox/WebKit.
      const name = (err as { name?: string }).name;
      let msg = 'Mikrofon konnte nicht gestartet werden.';
      if (name === 'NotAllowedError' || name === 'SecurityError') {
        msg =
          'Mikrofon-Zugriff blockiert. Erlaube den Zugriff in den Systemeinstellungen.';
      } else if (name === 'NotFoundError' || name === 'OverconstrainedError') {
        msg = 'Kein Mikrofon gefunden. Schließe eines an und versuche es erneut.';
      } else if (name === 'NotReadableError') {
        msg =
          'Mikrofon ist von einer anderen App belegt. Schließe sie und versuche es erneut.';
      }
      setMicError(msg);
      console.error('mic pipeline setup failed', err);
    }
  }, []);

  const disconnectRoom = useCallback(async () => {
    const r = roomRef.current;
    if (r) {
      try {
        await r.disconnect();
      } catch {
        /* ignore */
      }
    }
    if (nativeAudioCaptureRef.current) {
      const h = nativeAudioCaptureRef.current;
      nativeAudioCaptureRef.current = null;
      setIsCapturingSystemAudio(false);
      await h.stop().catch(() => undefined);
    }
    if (nativeLoopbackRef.current) {
      const h = nativeLoopbackRef.current;
      nativeLoopbackRef.current = null;
      setIsCapturingSystemAudio(false);
      await h.stop().catch(() => undefined);
    }
    roomRef.current = null;
    setRoom(null);
    setRemoteParticipants([]);
    setRemoteScreenShares([]);
    // Drop any deferred screen-share audio entries left from this call so
    // a fresh call doesn't auto-attach stale tracks if the same peer joins
    // again before watchShare clears them.
    deferredScreenAudio.clear();
    setIsScreenSharing(false);
    setIsCameraEnabled(false);
    setIsDeafened(false);
    deafenedActive = false;
    setRemoteDeafen({});
    setRemoteMute({});
    setIsMuted(false);
    mutedRef.current = false;
    setIsE2EEActive(false);

    // Tear down the mic pipeline AFTER LiveKit disconnects so the published
    // track is unpublished cleanly first; then close AudioContext + stop
    // raw mic + output tracks we own.
    const pipeline = pipelineRef.current;
    if (pipeline) {
      try {
        pipeline.destroy();
      } catch {
        /* ignore */
      }
      pipelineRef.current = null;
    }
    setActiveSoundboardIds(new Set<string>());

    const pres = presenceChannelRef.current;
    if (pres) {
      try {
        await pres.untrack();
      } catch {
        /* ignore */
      }
      // Don't removeChannel — observers (e.g. ActiveCallBanner, RejoinCallBar)
      // on the same topic share this instance via Supabase's topic dedupe.
      // Removing it would break their presence subscription. Untracking is
      // enough: the server drops our presence entry; observers see us leave.
      presenceChannelRef.current = null;
    }
  }, []);

  const sendSignal = useCallback(
    async (toUserId: string, payload: CallSignal) => {
      const ch = supabase.channel(signalTopic(toUserId));
      await ch.subscribe();
      await ch.send({ type: 'broadcast', event: 'signal', payload });
      window.setTimeout(() => {
        void supabase.removeChannel(ch);
      }, 400);
    },
    [],
  );

  // Pending-incoming helpers. Setting the state via the helper keeps the
  // ref mirror, ringtone, and timer in sync — handlers in the signal
  // listener should always go through these instead of touching state
  // directly.
  const clearPendingRingTimer = useCallback(() => {
    if (pendingRingTimerRef.current !== null) {
      window.clearTimeout(pendingRingTimerRef.current);
      pendingRingTimerRef.current = null;
    }
  }, []);

  const setPendingIncoming = useCallback(
    (next: PendingIncoming | null) => {
      pendingIncomingRef.current = next;
      setPendingIncomingState(next);
      if (next === null) clearPendingRingTimer();
    },
    [clearPendingRingTimer],
  );

  const peerUserIdsFor = useCallback(
    (conversationId: string): string[] => {
      const conv = conversations.find((c) => c.id === conversationId);
      if (!conv || !myId) return [];
      return conv.members.filter((m) => m.userId !== myId && m.accepted).map((m) => m.userId);
    },
    [conversations, myId],
  );

  // Transitions to 'connected' once at least one remote participant is in
  // the room. Also cancels the solo-timeout. No-op if already connected.
  const markConnectedIfReady = useCallback(
    (r: Room, conversationId: string, mediaKind: CallKind, callId: string) => {
      const cur = stateRef.current;
      if (cur.kind === 'connected') return;
      if (r.remoteParticipants.size === 0) return;
      clearRingTimer();
      clearSoloTimer();
      clearJoinFallbackTimer();
      everConnectedRef.current = true;
      setState({
        kind: 'connected',
        callId,
        conversationId,
        mediaKind,
        startedAt: new Date().toISOString(),
      });
    },
    [clearRingTimer, clearSoloTimer, clearJoinFallbackTimer],
  );

  // --- LiveKit join/leave ------------------------------------------------

  const joinRoom = useCallback(
    async (conversationId: string, mediaKind: CallKind, callId: string) => {
      const { token, url } = await fetchLivekitToken(supabase, conversationId);

      // Discord-style dynamic quality: VP9 + SVC L3T3_KEY emits 3 spatial ×
      // 3 temporal layers from a single encode. LiveKit's congestion control
      // drops temporal/spatial layers per-subscriber when uplink degrades,
      // so a 4K60 publisher downshifts smoothly to 720p30 on weak links
      // without a full renegotiate. `adaptiveStream` pauses video downlink
      // off-screen; `dynacast` stops publishing layers nobody subscribes to;
      // `degradationPreference: balanced` tells WebRTC to trade framerate vs
      // resolution dynamically based on the encoder's CPU + bandwidth.
      // Discord-style dynamic quality: VP9 + SVC L3T3_KEY emits 3 spatial ×
      // 3 temporal layers from a single encode. LiveKit's congestion control
      // drops temporal/spatial layers per-subscriber when uplink degrades,
      // so a 4K60 publisher downshifts smoothly to 720p30 on weak links
      // without a full renegotiate. `adaptiveStream` pauses video downlink
      // off-screen; `dynacast` stops publishing layers nobody subscribes to;
      // `degradationPreference: balanced` tells WebRTC to trade framerate vs
      // resolution dynamically based on the encoder's CPU + bandwidth.
      // Audio: `red` doubles Opus packets for packet-loss concealment;
      // `dtx` is off in hifi so music doesn't get zeroed out between notes;
      // `forceStereo` publishes stereo when the hifi preset captures stereo.
      const ssCfg = getScreenShareSettings();
      const ssParams = getPresetParams(ssCfg.preset);
      const aParams = getAudioQualityParams(getAudioSettings().quality);
      const e2eeCfg = getCallE2EESettings();
      const e2eeUsable = e2eeCfg.enabled && isE2EESupported();
      const e2eeBundle = e2eeUsable ? await createCallE2EE(conversationId) : null;
      const r = new Room({
        adaptiveStream: true,
        dynacast: true,
        publishDefaults: {
          videoCodec: 'vp9',
          scalabilityMode: 'L3T3_KEY',
          degradationPreference: 'balanced',
          backupCodec: true,
          audioPreset: {
            maxBitrate: aParams.bitrateKbps * 1000,
            priority: 'high',
          },
          red: true,
          dtx: aParams.stereo ? false : true,
          forceStereo: aParams.stereo,
          screenShareEncoding: {
            maxBitrate: ssParams.bitrateKbps * 1000,
            maxFramerate: ssParams.framerate,
            priority: 'high',
          },
        },
        ...(e2eeBundle
          ? {
              e2ee: {
                keyProvider: e2eeBundle.keyProvider,
                worker: e2eeBundle.worker,
              },
            }
          : {}),
      });
      roomRef.current = r;
      setRoom(r);

      r.on(RoomEvent.ConnectionStateChanged, (cs) => {
        if (cs === ConnectionState.Reconnecting) {
          // LiveKit lost the signaling socket and is retrying. Hold the
          // connected state visually — the track publications stay live,
          // so the user's mic + speakers keep working once the socket is
          // back. Only transition from `connected`; if we were still in
          // `connecting`/`outgoing`, LK will sort itself out on its own.
          const cur = stateRef.current;
          if (cur.kind === 'connected') {
            setState({
              kind: 'reconnecting',
              callId: cur.callId,
              conversationId: cur.conversationId,
              mediaKind: cur.mediaKind,
              startedAt: cur.startedAt,
            });
          }
          return;
        }
        if (cs === ConnectionState.Connected) {
          // Flip back from `reconnecting` when LK re-establishes the socket.
          // Preserves startedAt so the duration counter doesn't reset.
          const cur = stateRef.current;
          if (cur.kind === 'reconnecting') {
            setState({
              kind: 'connected',
              callId: cur.callId,
              conversationId: cur.conversationId,
              mediaKind: cur.mediaKind,
              startedAt: cur.startedAt,
            });
          }
          return;
        }
        if (cs === ConnectionState.Disconnected) {
          // Server / network tore us out — reset state cleanly. Remember the
          // conversation so the sidebar "still live — rejoin" widget stays
          // visible: observers will poll presence and detect any remaining
          // peers, which is the only cue this side gets that a call is live.
          clearRingTimer();
          clearSoloTimer();
          if (joinFallbackTimerRef.current !== null) {
            window.clearTimeout(joinFallbackTimerRef.current);
            joinFallbackTimerRef.current = null;
          }
          const wasInCall =
            stateRef.current.kind === 'connected' ||
            stateRef.current.kind === 'connecting' ||
            stateRef.current.kind === 'reconnecting' ||
            stateRef.current.kind === 'outgoing';
          if (wasInCall) {
            setLastCallConversationId(conversationId);
          }
          // Untrack our presence entry so observers see us leave. Keep the
          // channel alive — observers share it via Supabase topic dedupe.
          const pres = presenceChannelRef.current;
          if (pres) {
            try {
              void pres.untrack();
            } catch {
              /* ignore */
            }
            presenceChannelRef.current = null;
          }
          if (stateRef.current.kind !== 'idle' && stateRef.current.kind !== 'error') {
            setState({ kind: 'idle' });
          }
          roomRef.current = null;
          setRoom(null);
          setRemoteParticipants([]);
          setRemoteScreenShares([]);
          setConnectionQualities({});
          setCallHostId(null);
          setCaptions({});
          setIsScreenSharing(false);
          setIsE2EEActive(false);
        }
      });

      // Discord-style Wifi-Badge: LiveKit emits this whenever the SFU-measured
      // quality of any participant (incl. local) changes. Map identity ->
      // quality and feed every tile so we can show poor/lost glyphs.
      r.on(
        RoomEvent.ConnectionQualityChanged,
        (quality: ConnectionQuality, participant: Participant) => {
          setConnectionQualities((prev) => {
            if (prev[participant.identity] === quality) return prev;
            return { ...prev, [participant.identity]: quality };
          });
        },
      );

      r.on(RoomEvent.ParticipantConnected, () => {
        setRemoteParticipants(Array.from(r.remoteParticipants.values()));
        if (presenceRef.current !== 'dnd') void playJoinBeep();
        // Rejoin während wir schon `connected` sind: markConnectedIfReady
        // returnt früh und würde den Solo-Timer nicht cancellen. Hier
        // unbedingt clearen, sonst kickt der 5-Minuten-Timer obwohl der
        // andere Peer längst wieder im Raum ist.
        clearSoloTimer();
        markConnectedIfReady(r, conversationId, mediaKind, callId);
      });

      r.on(RoomEvent.ParticipantDisconnected, (participant) => {
        const remaining = Array.from(r.remoteParticipants.values());
        setRemoteParticipants(remaining);
        // Drop the leaver's quality entry so a stale "poor" badge doesn't
        // linger after they disconnect.
        setConnectionQualities((prev) => {
          if (!(participant.identity in prev)) return prev;
          const next = { ...prev };
          delete next[participant.identity];
          return next;
        });
        if (presenceRef.current !== 'dnd') void playLeaveBeep();
        // Alone in the room while connected — start the solo-timeout.
        if (
          stateRef.current.kind === 'connected' &&
          remaining.length === 0 &&
          soloTimerRef.current === null
        ) {
          soloTimerRef.current = window.setTimeout(() => {
            void (async () => {
              soloTimerRef.current = null;
              const cur = stateRef.current;
              if (cur.kind === 'connected') {
                const durationSec = Math.max(
                  0,
                  Math.floor((Date.now() - new Date(cur.startedAt).getTime()) / 1000),
                );
                void emitCallEvent(cur.conversationId, 'ended', cur.mediaKind, durationSec);
              }
              await disconnectRoom();
              void playEndBeep();
              // Room was empty by timeout — nothing to rejoin into.
              setLastCallConversationId(null);
              setState({ kind: 'idle' });
            })();
          }, SOLO_TIMEOUT_MS);
        }
      });

      r.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
        attachTrack(track, publication, participant);
        if (
          track.kind === Track.Kind.Video &&
          (track.source === Track.Source.ScreenShare ||
            publication.source === Track.Source.ScreenShare)
        ) {
          const identity = participant.identity;
          const name = participant.name || identity;
          setRemoteScreenShares((prev) => {
            if (prev.some((s) => s.track.sid === track.sid)) return prev;
            return [...prev, { track, participantId: identity, participantName: name }];
          });
        }
      });
      r.on(RoomEvent.TrackUnsubscribed, (track, publication, participant) => {
        detachTrack(track, publication, participant);
        if (track.kind === Track.Kind.Video) {
          const wasShare =
            track.source === Track.Source.ScreenShare ||
            publication.source === Track.Source.ScreenShare;
          setRemoteScreenShares((prev) => prev.filter((s) => s.track.sid !== track.sid));
          // Clean up watching / dismissed state for the sharer so a fresh
          // restart from the same user shows the overlay again (Discord
          // resets dismiss when a new stream begins).
          if (wasShare && participant.identity) {
            const identity = participant.identity;
            setWatchingShareUserIds((prev) => {
              if (!prev.has(identity)) return prev;
              const next = new Set(prev);
              next.delete(identity);
              return next;
            });
            setDismissedShareUserIds((prev) => {
              if (!prev.has(identity)) return prev;
              const next = new Set(prev);
              next.delete(identity);
              return next;
            });
            setScreenShareAudioMutedIds((prev) => {
              if (!prev.has(identity)) return prev;
              const next = new Set(prev);
              next.delete(identity);
              return next;
            });
          }
        }
      });

      // Mute/unmute a camera doesn't publish or unpublish — the publication
      // stays, just its `muted` flag flips. Without this listener, remote
      // participants who toggle video mid-call appear as a frozen last frame
      // or (worse) a black tile on every other client. Bumping the
      // remoteParticipants state reference forces buildTiles to re-read
      // `isCameraEnabled` and swap to the avatar placeholder.
      const bumpParticipants = () => {
        setRemoteParticipants(Array.from(r.remoteParticipants.values()));
      };
      r.on(RoomEvent.TrackMuted, bumpParticipants);
      r.on(RoomEvent.TrackUnmuted, bumpParticipants);
      // Remote deafen state is broadcast via the LiveKit data channel. We
      // store incoming states in `remoteDeafenMapRef` and bump participants
      // so buildTiles re-reads it.
      r.on(
        RoomEvent.DataReceived,
        (payload: Uint8Array, participant?: RemoteParticipant | undefined) => {
          if (!participant?.identity) return;
          try {
            const text = new TextDecoder().decode(payload);
            const msg = JSON.parse(text) as {
              type?: string;
              deafened?: boolean;
              muted?: boolean;
              captionText?: string;
              captionFinal?: boolean;
            };
            const id: string = participant.identity;
            if (msg.type === 'presence') {
              if (typeof msg.deafened === 'boolean') {
                const deafened: boolean = msg.deafened;
                setRemoteDeafen((prev) => {
                  if (prev[id] === deafened) return prev;
                  return { ...prev, [id]: deafened };
                });
              }
              if (typeof msg.muted === 'boolean') {
                const muted: boolean = msg.muted;
                setRemoteMute((prev) => {
                  if (prev[id] === muted) return prev;
                  return { ...prev, [id]: muted };
                });
              }
              return;
            }
            if (msg.type === 'caption' && typeof msg.captionText === 'string') {
              const text2 = msg.captionText;
              const final = msg.captionFinal === true;
              setCaptions((prev) => ({
                ...prev,
                [id]: { text: text2, final, timestamp: Date.now() },
              }));
              return;
            }
          } catch {
            /* ignore malformed */
          }
        },
      );
      // When someone joins, re-send our current presence (deafen + mute) so
      // they know immediately instead of waiting for the next toggle.
      r.on(RoomEvent.ParticipantConnected, () => {
        void broadcastPresence(r, deafenedActive, mutedRef.current);
      });

      // Track my own screen-share state via LocalTrack events so the toggle
      // stays in sync if the user stops sharing via the browser's native UI.
      r.on(RoomEvent.LocalTrackPublished, (publication) => {
        if (publication.source === Track.Source.ScreenShare) {
          setIsScreenSharing(true);
        }
      });
      r.on(RoomEvent.LocalTrackUnpublished, (publication) => {
        if (publication.source === Track.Source.ScreenShare) {
          setIsScreenSharing(false);
        }
      });

      await r.connect(url, token);
      // Self-join feedback sound. `playJoinBeep` is shared with the
      // ParticipantConnected path; firing it here too gives the user a clear
      // "I'm in the room" cue that Discord plays on self-join.
      if (presenceRef.current !== 'dnd') void playJoinBeep();
      if (e2eeBundle) {
        try {
          await r.setE2EEEnabled(true);
          setIsE2EEActive(true);
        } catch (e2eErr: unknown) {
          console.error('setE2EEEnabled failed — falling back to clear-media', e2eErr);
          setIsE2EEActive(false);
        }
      } else {
        setIsE2EEActive(false);
      }
      // Mic pipeline setup + publish. Runs asynchronously; on failure sets
      // `micError` so the InCallPanel renders a retry banner without tearing
      // down the whole call — the user can still hear peers meanwhile.
      await setupMicPipeline(r);
      if (mediaKind === 'video') {
        try {
          await r.localParticipant.setCameraEnabled(true);
          setIsCameraEnabled(true);
        } catch (camErr: unknown) {
          console.error('setCameraEnabled failed', camErr);
        }
      }
      setIsMuted(false);
      setRemoteParticipants(Array.from(r.remoteParticipants.values()));

      // If peers are already in the room, transition immediately — otherwise
      // stay in outgoing/connecting and wait for ParticipantConnected.
      // Runs BEFORE presence setup so a slow/failing presence channel can't
      // leave the acceptor stuck in "connecting…".
      markConnectedIfReady(r, conversationId, mediaKind, callId);

      // Advertise "we're in this call" on the presence channel so other
      // conversation members can show a Join-button even after leaving.
      // Supabase realtime dedupes channels by topic: if any component (e.g.
      // an observer banner) already subscribed to this topic, we get the
      // existing channel back. In that case .subscribe() would throw — so we
      // branch on channel.state and just track().
      if (myId) {
        const pres = supabase.channel('call-presence:' + conversationId, {
          config: { presence: { key: myId, enabled: true } },
        });
        presenceChannelRef.current = pres;
        const track = () => {
          void pres.track({ userId: myId, joinedAt: new Date().toISOString() });
        };
        if (pres.state === 'joined') {
          track();
        } else if (pres.state === 'closed') {
          pres.subscribe((status) => {
            if (status === 'SUBSCRIBED') track();
          });
        } else {
          // 'joining' — wait briefly for join to complete before tracking.
          const waitId = window.setInterval(() => {
            if (pres.state === 'joined') {
              window.clearInterval(waitId);
              track();
            }
          }, 100);
          window.setTimeout(() => window.clearInterval(waitId), 5000);
        }
      }
    },
    [
      markConnectedIfReady,
      clearRingTimer,
      clearSoloTimer,
      emitCallEvent,
      disconnectRoom,
      myId,
      setupMicPipeline,
    ],
  );

  // --- Public actions ----------------------------------------------------

  const startCall = useCallback(
    async (conversationId: string, mediaKind: CallKind = 'audio') => {
      if (!myId) return;
      if (stateRef.current.kind !== 'idle') return;

      const callId = newCallId();
      everConnectedRef.current = false;
      setLastCallConversationId(null);
      // Discord-style host marker — caller is the host of an outgoing call.
      setCallHostId(myId);
      setState({
        kind: 'outgoing',
        callId,
        conversationId,
        mediaKind,
        ringingSince: new Date().toISOString(),
      });

      const peers = peerUserIdsFor(conversationId);
      pendingPeersRef.current = new Set(peers);
      await Promise.all(
        peers.map((to) =>
          sendSignal(to, {
            type: 'invite',
            callId,
            conversationId,
            fromUserId: myId,
            kind: mediaKind,
            sentAt: new Date().toISOString(),
          }),
        ),
      );

      // Caller joins the room immediately so acceptors hop straight into a
      // live room. Transition to 'connected' happens in ParticipantConnected.
      try {
        await joinRoom(conversationId, mediaKind, callId);
      } catch (err: unknown) {
        setState({
          kind: 'error',
          message: err instanceof Error ? err.message : 'join failed',
        });
        await disconnectRoom();
        return;
      }

      clearRingTimer();
      ringTimerRef.current = window.setTimeout(() => {
        void (async () => {
          // Only trigger if still alone (nobody joined the room yet).
          const cur = stateRef.current;
          if (cur.kind !== 'outgoing' || cur.callId !== callId) return;
          const peers2 = peerUserIdsFor(conversationId);
          await Promise.all(
            peers2.map((to) =>
              sendSignal(to, { type: 'cancel', callId, byUserId: myId }),
            ),
          );
          void emitCallEvent(conversationId, 'missed', mediaKind, 0);
          await disconnectRoom();
          setState({ kind: 'idle' });
        })();
      }, RING_TIMEOUT_MS);
    },
    [myId, peerUserIdsFor, sendSignal, clearRingTimer, emitCallEvent, joinRoom, disconnectRoom],
  );

  // Re-join an already-running call without ringing peers.
  const joinActiveCall = useCallback(
    async (conversationId: string, mediaKind: CallKind = 'audio') => {
      if (!myId) return;
      if (stateRef.current.kind !== 'idle') return;
      const callId = newCallId();
      everConnectedRef.current = false;
      setLastCallConversationId(null);
      // Re-join doesn't carry an inviter — leave host unset. The crown
      // simply won't render in this case, which matches Discord's
      // "no clear host" rejoin behaviour.
      setCallHostId(null);
      setState({ kind: 'connecting', callId, conversationId, mediaKind });
      try {
        await joinRoom(conversationId, mediaKind, callId);
        // Fallback: peers may have left the room right as we joined, so
        // ParticipantConnected never fires. Promote to `connected` after a
        // short window so the UI doesn't sit in "Verbinde…" forever. The
        // solo-timeout then handles the "actually alone" case cleanly.
        clearJoinFallbackTimer();
        joinFallbackTimerRef.current = window.setTimeout(() => {
          joinFallbackTimerRef.current = null;
          const cur = stateRef.current;
          if (cur.kind !== 'connecting' || cur.callId !== callId) return;
          everConnectedRef.current = true;
          setState({
            kind: 'connected',
            callId,
            conversationId,
            mediaKind,
            startedAt: new Date().toISOString(),
          });
        }, 5000);
      } catch (err: unknown) {
        setState({
          kind: 'error',
          message: err instanceof Error ? err.message : 'join failed',
        });
        await disconnectRoom();
      }
    },
    [myId, joinRoom, disconnectRoom, clearJoinFallbackTimer],
  );

  const acceptIncoming = useCallback(
    async (override?: CallKind) => {
      const s = stateRef.current;
      if (s.kind !== 'incoming' || !myId) return;
      const { callId, conversationId, fromUserId } = s;
      // Caller's `mediaKind` is the INVITE kind (what they started with). The
      // receiver can accept with audio even if the caller rang as video, or
      // upgrade an audio invite to video on accept. `override` picks the
      // receiver's choice.
      const mediaKind: CallKind = override ?? s.mediaKind;
      everConnectedRef.current = false;
      setLastCallConversationId(null);
      // Discord-style host marker — original caller (the inviter) is host.
      setCallHostId(fromUserId);
      setState({ kind: 'connecting', callId, conversationId, mediaKind });
      try {
        await joinRoom(conversationId, mediaKind, callId);
      } catch (err: unknown) {
        setState({
          kind: 'error',
          message: err instanceof Error ? err.message : 'join failed',
        });
        await disconnectRoom();
      }
    },
    [myId, joinRoom, disconnectRoom],
  );

  const rejectIncoming = useCallback(() => {
    const s = stateRef.current;
    if (s.kind !== 'incoming' || !myId) return;
    void sendSignal(s.fromUserId, { type: 'reject', callId: s.callId, byUserId: myId });
    setState({ kind: 'idle' });
  }, [myId, sendSignal]);

  // Decline a Discord-style second-call ringer. Keeps the active call
  // running; only the pending invite is cleared and the caller notified.
  const rejectPendingIncoming = useCallback(() => {
    const pi = pendingIncomingRef.current;
    if (!pi || !myId) return;
    void sendSignal(pi.fromUserId, {
      type: 'reject',
      callId: pi.callId,
      byUserId: myId,
    });
    setPendingIncoming(null);
  }, [myId, sendSignal, setPendingIncoming]);

  const hangup = useCallback(async () => {
    const s = stateRef.current;
    clearRingTimer();
    clearSoloTimer();
    clearJoinFallbackTimer();

    if (s.kind === 'outgoing' && myId) {
      // Caller cancelled before anyone picked up — dismiss other sides' rings.
      const peers = peerUserIdsFor(s.conversationId);
      void Promise.all(
        peers.map((to) =>
          sendSignal(to, { type: 'cancel', callId: s.callId, byUserId: myId }),
        ),
      );
      if (!everConnectedRef.current) {
        void emitCallEvent(s.conversationId, 'missed', s.mediaKind, 0);
      }
    }

    let nextLastCallId: string | null = null;
    if (s.kind === 'connected') {
      const r = roomRef.current;
      const amLastOut = !r || r.remoteParticipants.size === 0;
      if (amLastOut) {
        const durationSec = Math.max(
          0,
          Math.floor((Date.now() - new Date(s.startedAt).getTime()) / 1000),
        );
        void emitCallEvent(s.conversationId, 'ended', s.mediaKind, durationSec);
      } else {
        // Peers still in room — keep sidebar rejoin affordance.
        nextLastCallId = s.conversationId;
      }
    }

    // Await disconnect BEFORE dropping state so the tracker presence channel
    // is removed before any RejoinCallBar observer tries to subscribe to the
    // same topic (Supabase realtime dedupes channels by topic).
    await disconnectRoom();
    void playEndBeep();
    setLastCallConversationId(nextLastCallId);
    setState({ kind: 'idle' });
  }, [
    myId,
    peerUserIdsFor,
    sendSignal,
    disconnectRoom,
    clearRingTimer,
    clearSoloTimer,
    clearJoinFallbackTimer,
    emitCallEvent,
  ]);

  const dismissLastCall = useCallback(() => {
    setLastCallConversationId(null);
  }, []);

  // Accept a Discord-style second-call ringer. Hangs up the currently
  // active call cleanly first (so the other side gets the proper end
  // signal + duration event) then joins the new room. Routing to the
  // new conversation is the toast's responsibility — it has access to
  // react-router's `navigate` from a component context.
  const acceptPendingIncoming = useCallback(async () => {
    const pi = pendingIncomingRef.current;
    if (!pi || !myId) return;
    setPendingIncoming(null);
    // Tear down whatever's currently active. hangup() already covers
    // outgoing/connected/reconnecting paths and resets state to idle, so
    // this is the same teardown a manual hangup would run.
    try {
      await hangup();
    } catch (err: unknown) {
      console.warn('hangup before pending-incoming accept failed', err);
    }
    everConnectedRef.current = false;
    setLastCallConversationId(null);
    // Discord-style host marker — original caller of the pending invite.
    setCallHostId(pi.fromUserId);
    setState({
      kind: 'connecting',
      callId: pi.callId,
      conversationId: pi.conversationId,
      mediaKind: pi.mediaKind,
    });
    try {
      await joinRoom(pi.conversationId, pi.mediaKind, pi.callId);
    } catch (err: unknown) {
      setState({
        kind: 'error',
        message: err instanceof Error ? err.message : 'join failed',
      });
      await disconnectRoom();
    }
  }, [myId, hangup, joinRoom, disconnectRoom, setPendingIncoming]);

  const toggleMute = useCallback(() => {
    const pipeline = pipelineRef.current;
    if (!pipeline) return;
    setIsMuted((prev) => {
      const nextMuted = !prev;
      pipeline.setMicGain(nextMuted ? 0 : 1);
      mutedRef.current = nextMuted;
      const r = roomRef.current;
      if (r) void broadcastPresence(r, deafenedActive, nextMuted);
      // Discord-style self-feedback blip. Always plays (even in DND) since
      // it's confirmation of the user's own action, not a notification.
      void (nextMuted ? playMuteBeep() : playUnmuteBeep());
      return nextMuted;
    });
  }, []);

  const startScreenShare = useCallback(
    async (
      overrides?: Partial<{
        preset: ScreenSharePreset;
        displaySurface: DisplaySurfaceHint;
        framerate: number | null;
        pickedSourceId: string;
      }>,
    ) => {
      const r = roomRef.current;
      if (!r) return;
      const lp = r.localParticipant;
      if (lp.isScreenShareEnabled) return;

      const settings = getScreenShareSettings();
      const preset = overrides?.preset ?? settings.preset;
      const displaySurface =
        overrides?.displaySurface !== undefined
          ? overrides.displaySurface
          : settings.displaySurface;
      const framerateOverride =
        overrides?.framerate !== undefined
          ? overrides.framerate
          : settings.framerateOverride;
      updateScreenShareSettings({ preset, displaySurface, framerateOverride });

      const ssParams = getPresetParams(preset);
      const fps = framerateOverride ?? ssParams.framerate;

      // Under Electron: setScreenShareEnabled(true) calls getDisplayMedia
      // internally; Chromium defers to the display-media-request-handler
      // configured in the main process (see electron/main.ts) which
      // silently grants the first screen source.
      //
      // Audio path:
      //   1. On Windows we *prefer* the native WASAPI process-loopback
      //      addon (lib/loopbackAudio.ts) — it's the only path that
      //      excludes our own PID tree, so peers never hear themselves
      //      echoed back. We pass `audio: false` to LiveKit so Chromium
      //      doesn't grant its own un-filtered 'loopback' on top of the
      //      native track. The native track is published as its own
      //      ScreenShareAudio publication AFTER setScreenShareEnabled
      //      returns.
      //   2. If the addon isn't available (dev build without
      //      pnpm build:native, packaged build missing the .node, or a
      //      runtime error) we fall through to passing
      //      `audio: settings.includeSystemAudio` to LiveKit on the
      //      retry — the existing Chromium-loopback path still works,
      //      just without process-tree exclusion.
      //   3. On macOS/Linux the addon throws "Windows only" so the
      //      same fallthrough lands on the LiveKit path, which tries
      //      Chromium's loopback and ultimately the manual
      //      startSystemAudioCapture path below.
      const wantAudio = settings.includeSystemAudio;
      const preferNativeLoopback =
        wantAudio &&
        typeof window !== 'undefined' &&
        window.electronAPI?.osPlatform === 'win32' &&
        !!window.electronAPI?.audioLoopback;
      try {
        await lp.setScreenShareEnabled(true, {
          // When the native addon path is available we suppress
          // Chromium's loopback grant entirely — otherwise we'd publish
          // the same system audio twice and Chromium's version would
          // include our own renderer playback.
          audio: preferNativeLoopback ? false : wantAudio,
          ...(ssParams.dims
            ? {
                resolution: {
                  width: ssParams.dims.width,
                  height: ssParams.dims.height,
                  frameRate: fps,
                },
              }
            : {
                resolution: {
                  width: 3840,
                  height: 2160,
                  frameRate: fps,
                },
              }),
          ...(displaySurface
            ? ({ displaySurface } as { displaySurface: DisplaySurfaceHint })
            : {}),
          contentHint: 'detail',
        });
        setIsScreenSharing(true);
      } catch (err: unknown) {
        console.error('setScreenShareEnabled failed', err);
        return;
      }

      if (!wantAudio) return;

      // Step 1: try the native WASAPI process-loopback addon. This is
      // the preferred path on Windows because it excludes our own PID
      // tree — Chromium's 'loopback' source captures the entire OS
      // mixer including our renderer's playback, which echoes peers
      // back at themselves. We only attempt this when `preferNativeLoopback`
      // was true (we already passed `audio: false` to LiveKit, so no
      // double-publish risk).
      if (preferNativeLoopback) {
        try {
          // For window-shares we want only the picked app's audio
          // (Discord parity) — INCLUDE_TARGET_PROCESS_TREE on the HWND's
          // owning process. desktopCapturer hands us source ids shaped
          // `window:<HWND>:0`; the regex extracts the decimal HWND so
          // the addon can resolve it via GetWindowThreadProcessId. For
          // screen-shares (or unparseable ids) we fall through to the
          // default EXCLUDE-self path (whole OS mixer minus our PID).
          const pickedId = overrides?.pickedSourceId;
          let windowHwnd: number | undefined;
          if (typeof pickedId === 'string') {
            const m = /^window:(\d+):/.exec(pickedId);
            if (m && m[1]) {
              const parsed = Number(m[1]);
              if (Number.isFinite(parsed) && parsed > 0) {
                windowHwnd = parsed;
              }
            }
          }
          const handle = await startLoopbackTrack(
            windowHwnd !== undefined ? { windowHwnd } : undefined,
          );
          nativeLoopbackRef.current = handle;
          setIsCapturingSystemAudio(true);
          const audioPub = await lp.publishTrack(handle.track, {
            source: Track.Source.ScreenShareAudio,
          });
          let videoMst: MediaStreamTrack | null = null;
          for (const pub of lp.videoTrackPublications.values()) {
            if (pub.source === Track.Source.ScreenShare && pub.track) {
              videoMst = pub.track.mediaStreamTrack;
              break;
            }
          }
          const teardown = () => {
            void (async () => {
              try {
                if (audioPub.track) await lp.unpublishTrack(audioPub.track);
              } catch {
                /* already unpublished */
              }
              const active = nativeLoopbackRef.current;
              if (active === handle) {
                nativeLoopbackRef.current = null;
                setIsCapturingSystemAudio(false);
                await active.stop().catch(() => undefined);
              }
            })();
          };
          handle.track.addEventListener('ended', teardown);
          if (videoMst) videoMst.addEventListener('ended', teardown);
          return;
        } catch (err: unknown) {
          console.warn(
            'screen-share: native loopback addon failed, falling back',
            err instanceof Error ? err.message : err,
          );
          // Fall through to the existing fallback paths below.
        }
      }

      // If Chromium's native path already landed a ScreenShareAudio
      // track (display-media handler granted 'loopback' and the OS
      // produced an audio track), we're done — spinning up the manual
      // capture on top would double-publish audio.
      for (const pub of lp.audioTrackPublications.values()) {
        if (pub.source === Track.Source.ScreenShareAudio) {
          // Native loopback is live → enable the JS-level auto-duck of
          // remote call audio so peers don't echo back through the
          // capture. Cleared in stopScreenShare/disconnectRoom.
          setIsCapturingSystemAudio(true);
          return;
        }
      }

      // Fallback: manual loopback via screenAudio.ts for hosts where
      // the native path produced no audio track (window-share, macOS
      // without loopback grant). Publishes as its own ScreenShareAudio
      // track and chains teardown to the ScreenShare video track's
      // `ended` event so the "Stop sharing" overlay kills both sides
      // together.
      try {
        const audioHandle = await startSystemAudioCapture();
        nativeAudioCaptureRef.current = audioHandle;
        setIsCapturingSystemAudio(true);
        const audioMst = audioHandle.stream.getAudioTracks()[0];
        if (!audioMst) {
          await audioHandle.stop();
          nativeAudioCaptureRef.current = null;
          setIsCapturingSystemAudio(false);
          return;
        }
        const audioPub = await lp.publishTrack(audioMst, {
          source: Track.Source.ScreenShareAudio,
        });
        let videoMst: MediaStreamTrack | null = null;
        for (const pub of lp.videoTrackPublications.values()) {
          if (pub.source === Track.Source.ScreenShare && pub.track) {
            videoMst = pub.track.mediaStreamTrack;
            break;
          }
        }
        const teardown = () => {
          void (async () => {
            try {
              if (audioPub.track) await lp.unpublishTrack(audioPub.track);
            } catch {
              /* already unpublished */
            }
            const active = nativeAudioCaptureRef.current;
            if (active && active.captureId === audioHandle.captureId) {
              nativeAudioCaptureRef.current = null;
              setIsCapturingSystemAudio(false);
              await active.stop().catch(() => undefined);
            }
          })();
        };
        audioMst.addEventListener('ended', teardown);
        if (videoMst) videoMst.addEventListener('ended', teardown);
      } catch (err: unknown) {
        console.warn(
          'screen-share: native system-audio unavailable, sharing video only',
          err instanceof Error ? err.message : err,
        );
        if (nativeAudioCaptureRef.current) {
          await nativeAudioCaptureRef.current.stop().catch(() => undefined);
          nativeAudioCaptureRef.current = null;
          setIsCapturingSystemAudio(false);
        }
      }
    },
    [],
  );

  const stopScreenShare = useCallback(async () => {
    if (nativeAudioCaptureRef.current) {
      const h = nativeAudioCaptureRef.current;
      nativeAudioCaptureRef.current = null;
      setIsCapturingSystemAudio(false);
      try {
        await h.stop();
      } catch (err: unknown) {
        console.warn('native audio capture stop failed', err);
      }
    }
    if (nativeLoopbackRef.current) {
      const h = nativeLoopbackRef.current;
      nativeLoopbackRef.current = null;
      setIsCapturingSystemAudio(false);
      try {
        await h.stop();
      } catch (err: unknown) {
        console.warn('native loopback addon stop failed', err);
      }
    }
    const r = roomRef.current;
    if (!r) return;
    const lp = r.localParticipant;
    // Unpublish any ScreenShareAudio track we manually published alongside
    // setScreenShareEnabled. LiveKit's setScreenShareEnabled(false) only
    // drops the video track it captured itself.
    for (const pub of lp.audioTrackPublications.values()) {
      if (pub.source === Track.Source.ScreenShareAudio && pub.track) {
        await lp.unpublishTrack(pub.track).catch(() => undefined);
      }
    }
    if (lp.isScreenShareEnabled) {
      try {
        await lp.setScreenShareEnabled(false);
      } catch (err: unknown) {
        console.error('stopScreenShare failed', err);
      }
    }
    // Clear the auto-duck flag for the Chromium-native path too, where
    // nativeAudioCaptureRef was never populated but the flag was set when
    // the loopback ScreenShareAudio publication landed.
    setIsCapturingSystemAudio(false);
    setIsScreenSharing(false);
    setOutgoingShareAudioMutedState(false);
  }, []);

  // Sender-side mute on our own outgoing ScreenShareAudio. Iterates the
  // local audio publications instead of holding a ref because both the
  // Chromium-native path and the WASAPI fallback path can publish this
  // source — we just want whichever publication exists right now. mute() /
  // unmute() on a LocalTrackPublication signals LiveKit to stop sending
  // packets without unpublishing, which is exactly what we want here:
  // peers see the muted flag and our network use drops to ~zero, but the
  // video track + capture pipeline are untouched so toggling back is
  // instant.
  const toggleOutgoingShareAudioMute = useCallback(async () => {
    const r = roomRef.current;
    if (!r) return;
    const lp = r.localParticipant;
    const next = !outgoingShareAudioMuted;
    let touched = false;
    for (const pub of lp.audioTrackPublications.values()) {
      if (pub.source !== Track.Source.ScreenShareAudio) continue;
      const track = pub.track;
      if (!track) continue;
      try {
        if (next) await track.mute();
        else await track.unmute();
        touched = true;
      } catch (err: unknown) {
        console.warn('outgoing share-audio mute toggle failed', err);
      }
    }
    if (touched) setOutgoingShareAudioMutedState(next);
  }, [outgoingShareAudioMuted]);

  // Legacy toggle kept for convenience elsewhere — opens/closes with the
  // last-persisted settings and no picker UI.
  const toggleScreenShare = useCallback(async () => {
    const r = roomRef.current;
    if (!r) return;
    if (r.localParticipant.isScreenShareEnabled) {
      await stopScreenShare();
    } else {
      await startScreenShare();
    }
  }, [startScreenShare, stopScreenShare]);

  const toggleDeafen = useCallback(() => {
    setIsDeafened((prev) => {
      const next = !prev;
      deafenedActive = next;
      // Remote-audio gain is recomputed by the effective-gain useEffect
      // as soon as React picks up the new `isDeafened` state — no DOM
      // iteration needed here.

      // Discord-parity: deafen implies mute. Remember the pre-deafen mute
      // state so un-deafening restores whatever the user had before.
      const pipeline = pipelineRef.current;
      let nextMuted = mutedRef.current;
      if (next) {
        // Activating deafen → snapshot current mute + force mic off.
        preDeafenMutedRef.current = mutedRef.current;
        if (!mutedRef.current) {
          pipeline?.setMicGain(0);
          mutedRef.current = true;
          nextMuted = true;
          setIsMuted(true);
        }
      } else {
        // Deactivating deafen → restore pre-deafen mic state (if we have a
        // snapshot). Absent snapshot (e.g. reconnect edge), unmute.
        const restore = preDeafenMutedRef.current ?? false;
        preDeafenMutedRef.current = null;
        pipeline?.setMicGain(restore ? 0 : 1);
        mutedRef.current = restore;
        nextMuted = restore;
        setIsMuted(restore);
      }

      // Broadcast via LiveKit data channel so peers' UIs can show the
      // headphones-off badge. Data channel works on any LiveKit server
      // version, unlike `setAttributes` which requires a newer server.
      const r = roomRef.current;
      if (r) void broadcastPresence(r, next, nextMuted);
      // Discord-style deeper self-feedback blip — distinct tone from mute so
      // the user can tell the two states apart by ear alone.
      void (next ? playDeafenBeep() : playUndeafenBeep());
      return next;
    });
  }, []);

  const pushLocalCaption = useCallback(
    (text: string, final: boolean) => {
      if (!myId) return;
      setCaptions((prev) => ({
        ...prev,
        [myId]: { text, final, timestamp: Date.now() },
      }));
    },
    [myId],
  );

  const toggleCamera = useCallback(async () => {
    const r = roomRef.current;
    if (!r) return;
    const lp = r.localParticipant;
    const nextOn = !lp.isCameraEnabled;
    try {
      // Discord-parity: honor the user's preferred camera deviceId from
      // settings. LiveKit picks up `deviceId` here on first publish; runtime
      // switching is handled by setVideoInputDevice below.
      const settings = getAudioSettings();
      const captureOpts = settings.videoInputDeviceId
        ? { deviceId: settings.videoInputDeviceId }
        : undefined;
      await lp.setCameraEnabled(nextOn, captureOpts);
      setIsCameraEnabled(nextOn);
      if (nextOn && settings.videoBackgroundBlur) {
        void applyBackgroundBlurToLocal(lp);
      }
    } catch (err: unknown) {
      console.error('setCameraEnabled failed', err);
      // Permission denied / no camera — keep state in sync with actual
      // publication state so the button doesn't lie.
      setIsCameraEnabled(lp.isCameraEnabled);
    }
  }, []);

  // Hot-swap the camera device on an active publication. Mirrors
  // setAudioInputDevice for symmetry. Persists the choice so future
  // publishes pick it up automatically.
  const setVideoInputDevice = useCallback(async (deviceId: string | null) => {
    updateAudioSettings({ videoInputDeviceId: deviceId });
    const r = roomRef.current;
    if (!r) return;
    const lp = r.localParticipant;
    if (!lp.isCameraEnabled) return;
    try {
      // LiveKit's switchActiveDevice swaps the underlying source without
      // republishing. Empty string falls back to default.
      await r.switchActiveDevice('videoinput', deviceId ?? '');
    } catch (err: unknown) {
      console.warn('switchActiveDevice videoinput failed', err);
    }
  }, []);

  // Live-toggle the background-blur processor when the settings flag flips.
  // Acquiring the MediaPipe model is deferred until first activation to
  // avoid the 1.5MB download on users who never enable blur.
  useEffect(() => {
    return subscribeAudioSettings((s) => {
      const r = roomRef.current;
      if (!r) return;
      const lp = r.localParticipant;
      if (!lp.isCameraEnabled) return;
      if (s.videoBackgroundBlur) {
        void applyBackgroundBlurToLocal(lp);
      } else {
        void removeBackgroundBlurFromLocal(lp);
      }
    });
  }, []);

  // Hot-swap the mic track when the user flips noiseSuppression in Settings
  // so the change takes effect without needing to rejoin the call. Tracks
  // the previous value in a ref so we only re-acquire getUserMedia on
  // actual transitions (avoid a rebuild on every unrelated settings save).
  useEffect(() => {
    let lastNs = getAudioSettings().noiseSuppression;
    return subscribeAudioSettings((s) => {
      if (s.noiseSuppression === lastNs) return;
      lastNs = s.noiseSuppression;
      const r = roomRef.current;
      if (!r) return;
      // setupMicPipeline re-reads audioSettings so the new NS constraint
      // gets picked up. Same helper as the initial join + retry paths.
      void setupMicPipeline(r);
    });
  }, [setupMicPipeline]);

  // --- Push-to-talk ------------------------------------------------------
  // While PTT is active + we're in a connected call, the mic is held off
  // except while the configured key is pressed. Under Tauri we also register
  // the key as an OS-level global shortcut so PTT keeps working while the
  // user is focused on another window (Discord-style). The window listener
  // stays as a fallback for the web build and for the case where the global
  // shortcut registration fails (collision with another app).
  useEffect(() => {
    if (state.kind !== 'connected') return;

    let settings = getPttSettings();
    let activeKeyDown = false;
    let globalRegisteredFor: string | null = null;

    const setMic = (on: boolean) => {
      const pipeline = pipelineRef.current;
      if (!pipeline) return;
      pipeline.setMicGain(on ? 1 : 0);
      const nextMuted = !on;
      mutedRef.current = nextMuted;
      setIsMuted(nextMuted);
      const r = roomRef.current;
      if (r) void broadcastPresence(r, deafenedActive, nextMuted);
    };

    const pressPtt = () => {
      if (activeKeyDown) return;
      activeKeyDown = true;
      setMic(true);
    };
    const releasePtt = () => {
      if (!activeKeyDown) return;
      activeKeyDown = false;
      setMic(false);
    };

    const isTypingTarget = (el: EventTarget | null): boolean => {
      if (!(el instanceof HTMLElement)) return false;
      const tag = el.tagName;
      if (tag === 'INPUT' || tag === 'TEXTAREA') return true;
      if (el.isContentEditable) return true;
      return false;
    };

    const onKeyDown = (e: KeyboardEvent) => {
      if (!settings.enabled) return;
      if (e.code !== settings.key) return;
      if (isTypingTarget(e.target)) return;
      if (e.repeat) return;
      pressPtt();
    };
    const onKeyUp = (e: KeyboardEvent) => {
      if (!settings.enabled) return;
      if (e.code !== settings.key) return;
      releasePtt();
    };

    const ensureGlobalShortcut = () => {
      if (!isTauriRuntime()) return;
      if (!settings.enabled) {
        if (globalRegisteredFor) {
          const key = globalRegisteredFor;
          globalRegisteredFor = null;
          void unregisterPttShortcut(key);
        }
        return;
      }
      if (globalRegisteredFor === settings.key) return;
      // Hotkey changed — unregister old, register new.
      const oldKey = globalRegisteredFor;
      globalRegisteredFor = settings.key;
      if (oldKey) void unregisterPttShortcut(oldKey);
      void registerPttShortcut(settings.key, pressPtt, releasePtt);
    };

    const applyPttState = () => {
      if (settings.enabled) {
        activeKeyDown = false;
        setMic(false);
      } else {
        setMic(true);
      }
      ensureGlobalShortcut();
    };
    applyPttState();

    const unsubSettings = subscribePttSettings((next) => {
      settings = next;
      applyPttState();
    });

    window.addEventListener('keydown', onKeyDown);
    window.addEventListener('keyup', onKeyUp);

    return () => {
      unsubSettings();
      window.removeEventListener('keydown', onKeyDown);
      window.removeEventListener('keyup', onKeyUp);
      if (globalRegisteredFor) {
        void unregisterPttShortcut(globalRegisteredFor);
        globalRegisteredFor = null;
      }
    };
  }, [state.kind]);

  // --- Mute / Deafen global hotkeys --------------------------------------
  // Discord-parity: both toggles respond to a user-configurable chord
  // (default Ctrl+Shift+M / Ctrl+Shift+D, disabled until the user opts in).
  // Registered only while a call is active so the shortcuts don't intercept
  // typing outside of calls. Under Tauri we register an OS-level chord so
  // mute/deafen work from any focused window; the window keydown listener
  // is the fallback for the web build + when the global register fails
  // (another app owns the chord).
  useEffect(() => {
    if (
      state.kind !== 'connected' &&
      state.kind !== 'reconnecting'
    ) {
      return;
    }
    let settings: VoiceHotkeys = getVoiceHotkeys();
    type HotkeyKind = 'mute' | 'deafen' | 'hangup' | 'screenShare' | 'video';
    const KINDS: readonly HotkeyKind[] = [
      'mute',
      'deafen',
      'hangup',
      'screenShare',
      'video',
    ];
    let registered: Record<HotkeyKind, string | null> = {
      mute: null,
      deafen: null,
      hangup: null,
      screenShare: null,
      video: null,
    };

    const fire = (kind: HotkeyKind) => {
      switch (kind) {
        case 'mute':
          toggleMute();
          return;
        case 'deafen':
          toggleDeafen();
          return;
        case 'hangup':
          void hangup();
          return;
        case 'screenShare':
          void toggleScreenShare();
          return;
        case 'video':
          void toggleCamera();
          return;
      }
    };

    const onKey = (e: KeyboardEvent) => {
      for (const kind of KINDS) {
        if (eventMatchesBinding(settings[kind], e)) {
          e.preventDefault();
          fire(kind);
          return;
        }
      }
    };
    window.addEventListener('keydown', onKey);

    const syncGlobalShortcuts = () => {
      if (!isTauriRuntime()) return;
      const desired: Record<HotkeyKind, string | null> = {
        mute: settings.mute.enabled ? bindingToTauriShortcut(settings.mute) : null,
        deafen: settings.deafen.enabled ? bindingToTauriShortcut(settings.deafen) : null,
        hangup: settings.hangup.enabled ? bindingToTauriShortcut(settings.hangup) : null,
        screenShare: settings.screenShare.enabled
          ? bindingToTauriShortcut(settings.screenShare)
          : null,
        video: settings.video.enabled ? bindingToTauriShortcut(settings.video) : null,
      };
      for (const kind of KINDS) {
        const want = desired[kind];
        const have = registered[kind];
        if (want === have) continue;
        if (have) {
          void unregisterGlobalShortcut(have);
          registered = { ...registered, [kind]: null };
        }
        if (want) {
          // Tag the closure so React's stale-state trap doesn't bite —
          // `fire` is stable (defined above), and `kind` is captured by
          // value.
          const thisKind = kind;
          void registerGlobalShortcutPress(want, () => fire(thisKind));
          registered = { ...registered, [kind]: want };
        }
      }
    };

    syncGlobalShortcuts();
    const unsub = subscribeVoiceHotkeys((next) => {
      settings = next;
      syncGlobalShortcuts();
    });

    return () => {
      unsub();
      window.removeEventListener('keydown', onKey);
      for (const kind of KINDS) {
        const have = registered[kind];
        if (have) void unregisterGlobalShortcut(have);
      }
    };
  }, [state.kind, toggleMute, toggleDeafen, hangup, toggleScreenShare, toggleCamera]);

  // --- Outgoing-channel: listen for accept/reject on our own invite ------
  // and incoming invites from peers.
  useEffect(() => {
    if (!myId) return;
    const me: string = myId; // narrowed capture for the inner closure
    const topic = signalTopic(me);
    const channel = supabase
      .channel(topic, { config: { broadcast: { self: false } } })
      .on('broadcast', { event: 'signal' }, (msg) => {
        const payload = msg.payload as CallSignal;
        handleSignal(payload);
      });
    signalChannelRef.current = channel;
    void channel.subscribe();

    return () => {
      signalChannelRef.current = null;
      void supabase.removeChannel(channel);
    };

    function handleSignal(p: CallSignal) {
      const cur = stateRef.current;
      switch (p.type) {
        case 'invite': {
          // Discord-style second-call ringer: if we're already in an
          // active call (connected or temporarily reconnecting), surface
          // the new invite as a pendingIncoming toast instead of
          // auto-rejecting. The active call keeps running until the user
          // either accepts (which hangs it up cleanly) or rejects/lets
          // it ring out.
          const inActiveCall =
            cur.kind === 'connected' || cur.kind === 'reconnecting';
          if (inActiveCall) {
            // Only one slot — second pending invite while one is already
            // queued still gets a busy-reject.
            if (pendingIncomingRef.current) {
              void sendSignal(p.fromUserId, {
                type: 'reject',
                callId: p.callId,
                byUserId: me,
              });
              return;
            }
            const conv =
              conversationsRef.current.find((c) => c.id === p.conversationId) ?? null;
            const callerName =
              conv?.members.find((m) => m.userId === p.fromUserId)?.profile?.displayName ??
              conv?.peer?.displayName ??
              '…';
            const isGroup = conv?.type === 'group';
            setPendingIncoming({
              callId: p.callId,
              conversationId: p.conversationId,
              fromUserId: p.fromUserId,
              mediaKind: p.kind,
            });
            // Same 45s ring-out as the normal incoming flow. On expiry we
            // send a reject so the caller's UI clears.
            pendingRingTimerRef.current = window.setTimeout(() => {
              const pi = pendingIncomingRef.current;
              if (!pi || pi.callId !== p.callId) return;
              void sendSignal(pi.fromUserId, {
                type: 'reject',
                callId: pi.callId,
                byUserId: me,
              });
              setPendingIncoming(null);
            }, RING_TIMEOUT_MS);
            if (presenceRef.current !== 'dnd') {
              void notify({
                title: isGroup
                  ? (conv?.name ?? 'Gruppenanruf')
                  : 'Eingehender Anruf',
                body: isGroup
                  ? callerName + ' ruft die Gruppe'
                  : callerName + ' ruft dich an',
              });
            }
            return;
          }
          if (cur.kind !== 'idle') {
            // Outgoing / connecting / already-incoming — busy.
            void sendSignal(p.fromUserId, {
              type: 'reject',
              callId: p.callId,
              byUserId: me,
            });
            return;
          }
          setState({
            kind: 'incoming',
            callId: p.callId,
            conversationId: p.conversationId,
            fromUserId: p.fromUserId,
            mediaKind: p.kind,
          });
          const conv = conversationsRef.current.find((c) => c.id === p.conversationId) ?? null;
          const callerName =
            conv?.members.find((m) => m.userId === p.fromUserId)?.profile?.displayName ??
            conv?.peer?.displayName ??
            '…';
          const isGroup = conv?.type === 'group';
          if (presenceRef.current !== 'dnd') {
            void notify({
              title: isGroup
                ? (conv?.name ?? 'Gruppenanruf')
                : 'Eingehender Anruf',
              body: isGroup
                ? callerName + ' ruft die Gruppe'
                : callerName + ' ruft dich an',
            });
          }
          break;
        }
        case 'accept':
        case 'end': {
          // No-op. Participant join/leave is tracked via LiveKit events so
          // calls stay active while at least one person remains in the room.
          break;
        }
        case 'reject': {
          // Only relevant while I'm still the outgoing-caller who hasn't
          // reached the room. Once someone joined (state = connected), a late
          // reject from another ringing peer doesn't end the call.
          if (
            cur.kind !== 'outgoing' ||
            cur.callId !== p.callId ||
            everConnectedRef.current
          ) {
            break;
          }
          // Remove the rejecter from the pending set. For group calls, keep
          // ringing as long as at least one peer hasn't answered — only the
          // last reject collapses the call to `declined`.
          pendingPeersRef.current.delete(p.byUserId);
          if (pendingPeersRef.current.size > 0) break;
          clearRingTimer();
          clearSoloTimer();
          void emitCallEvent(cur.conversationId, 'declined', cur.mediaKind, 0);
          void disconnectRoom();
          setState({ kind: 'idle' });
          break;
        }
        case 'cancel': {
          // Caller cancelled before anyone picked up. May be the regular
          // incoming ringer or — when the receiver was already in another
          // call — a pendingIncoming toast.
          const pi = pendingIncomingRef.current;
          if (pi && pi.callId === p.callId) {
            setPendingIncoming(null);
            // No notification spam here — the toast just disappears,
            // matching Discord's behaviour for a withdrawn second call.
          }
          if (cur.kind === 'incoming' && cur.callId === p.callId) {
            const conv = conversationsRef.current.find((c) => c.id === cur.conversationId) ?? null;
            const callerName =
              conv?.members.find((m) => m.userId === cur.fromUserId)?.profile?.displayName ??
              conv?.peer?.displayName ??
              '…';
            if (presenceRef.current !== 'dnd') {
              void notify({
                title: 'Verpasster Anruf',
                body: callerName + ' hat aufgelegt',
              });
            }
            setState({ kind: 'idle' });
          }
          break;
        }
      }
    }
  }, [myId, clearRingTimer, clearSoloTimer, disconnectRoom, sendSignal, emitCallEvent, setPendingIncoming]);

  const setCallMode = useCallback((mode: CallMode) => {
    setCallModeState(mode);
  }, []);

  const setFocusedId = useCallback((id: string | null) => {
    setFocusedIdState(id);
  }, []);

  const markActive = useCallback((id: string, on: boolean) => {
    setActiveSoundboardIds((prev) => {
      const has = prev.has(id);
      if (on && has) return prev;
      if (!on && !has) return prev;
      const next = new Set(prev);
      if (on) next.add(id);
      else next.delete(id);
      return next;
    });
  }, []);

  const playSoundboard = useCallback(
    async (id: string, opts?: { overlap?: boolean }) => {
      const pipeline = pipelineRef.current;
      if (!pipeline) return;
      const entries = await listSoundboard();
      const entry = entries.find((e) => e.id === id);
      if (!entry) return;
      const handle = await playEntry(pipeline, entry, {
        ...(opts?.overlap !== undefined ? { overlap: opts.overlap } : {}),
        onEnded: () => markActive(id, false),
      });
      if (handle) markActive(id, true);
    },
    [markActive],
  );

  const stopSoundboard = useCallback(
    (id?: string) => {
      const pipeline = pipelineRef.current;
      if (!pipeline) return;
      pipeline.stopAll(id);
      if (id) {
        markActive(id, false);
      } else {
        setActiveSoundboardIds(new Set<string>());
      }
    },
    [markActive],
  );

  const setSoundboardMasterGain = useCallback(async (value: number) => {
    const prefs = await updateSoundboardPrefs({ masterGain: value });
    pipelineRef.current?.setSoundboardGain(prefs.masterGain);
  }, []);

  const setSoundboardMonitorGain = useCallback(async (value: number) => {
    const prefs = await updateSoundboardPrefs({ monitorGain: value });
    pipelineRef.current?.setMonitorGain(prefs.monitorGain);
  }, []);

  // Global soundboard hotkey registration — runs only while connected so the
  // OS-level shortcuts don't fire when the user is outside of a call.
  useEffect(() => {
    if (state.kind !== 'connected') return;
    const teardown = startSoundboardHotkeys((id) => {
      void playSoundboard(id);
    });
    return teardown;
  }, [state.kind, playSoundboard]);

  const setAudioInputDevice = useCallback(async (deviceId: string | null) => {
    updateAudioSettings({ inputDeviceId: deviceId });
    const pipeline = pipelineRef.current;
    if (!pipeline) return;
    try {
      // We own the mic track (see joinRoom pipeline setup), so LiveKit's
      // switchActiveDevice no longer applies. Fetch a new raw track with the
      // updated deviceId + the same quality constraints, then hand ownership
      // to the pipeline. It disconnects the old source, stops the old track,
      // and rewires micGain onto the new source — the published track stays
      // stable so peers don't see a republish.
      const audioPrefs = getAudioSettings();
      const aParams = getAudioQualityParams(audioPrefs.quality);
      const nsEffective = audioPrefs.noiseSuppression && aParams.noiseSuppression;
      const newStream = await navigator.mediaDevices.getUserMedia({
        audio: {
          echoCancellation: aParams.echoCancellation,
          noiseSuppression: nsEffective,
          autoGainControl: aParams.autoGainControl,
          channelCount: aParams.stereo ? 2 : 1,
          sampleRate: aParams.sampleRateHz,
          ...(deviceId ? { deviceId: { ideal: deviceId } } : {}),
        },
        video: false,
      });
      const newTrack = newStream.getAudioTracks()[0];
      if (!newTrack) throw new Error('no audio track for device');
      pipeline.replaceMicTrack(newTrack);
    } catch (err: unknown) {
      console.error('setAudioInputDevice failed', err);
    }
  }, []);

  const clearMicError = useCallback(() => {
    setMicError(null);
  }, []);

  const watchShare = useCallback((userId: string) => {
    setWatchingShareUserIds((prev) => {
      if (prev.has(userId)) return prev;
      const next = new Set(prev);
      next.add(userId);
      // Sync the mirror immediately so the flush below sees this user as
      // "watching" — without this, attachTrack inside flush would re-defer
      // the same entry because the React-effect mirror update hasn't
      // run yet.
      watchingShareUserIdsMirror = next;
      return next;
    });
    // Un-dismiss in case the user had dismissed earlier in the session and
    // now wants to watch again (Discord also lets you re-subscribe).
    setDismissedShareUserIds((prev) => {
      if (!prev.has(userId)) return prev;
      const next = new Set(prev);
      next.delete(userId);
      return next;
    });
    // Now perform the actual track.attach() for any ScreenShareAudio
    // publications we deferred while the user wasn't watching. Video is
    // already gated on the `watching` boolean inside ScreenShareViewer
    // so it just re-renders on the state flip.
    flushDeferredScreenAudio(userId);
  }, []);

  const stopWatchingShare = useCallback((userId: string) => {
    setWatchingShareUserIds((prev) => {
      if (!prev.has(userId)) return prev;
      const next = new Set(prev);
      next.delete(userId);
      return next;
    });
  }, []);

  const dismissShare = useCallback((userId: string) => {
    setWatchingShareUserIds((prev) => {
      if (!prev.has(userId)) return prev;
      const next = new Set(prev);
      next.delete(userId);
      return next;
    });
    setDismissedShareUserIds((prev) => {
      if (prev.has(userId)) return prev;
      const next = new Set(prev);
      next.add(userId);
      return next;
    });
  }, []);

  const setScreenShareAudioMuted = useCallback(
    (userId: string, muted: boolean) => {
      setScreenShareAudioMutedIds((prev) => {
        const has = prev.has(userId);
        if (muted === has) return prev;
        const next = new Set(prev);
        if (muted) next.add(userId);
        else next.delete(userId);
        return next;
      });
    },
    [],
  );

  const retryMic = useCallback(async () => {
    const r = roomRef.current;
    if (!r) {
      setMicError(null);
      return;
    }
    await setupMicPipeline(r);
  }, [setupMicPipeline]);

  // Clear the stale mic-error + screen-share session state whenever a call
  // fully tears down so the next join starts with a clean slate.
  useEffect(() => {
    if (state.kind === 'idle' || state.kind === 'error') {
      setMicError(null);
      setWatchingShareUserIds(new Set<string>());
      setDismissedShareUserIds(new Set<string>());
      setScreenShareAudioMutedIds(new Set<string>());
      clearScreenShareVolumes();
    }
  }, [state.kind]);

  // Mirror the watching / manual-mute sets into module-level variables so
  // attachTrack (which runs outside the React render cycle) can read them
  // when a ScreenShareAudio track lands. The runtime recompute for already-
  // attached tracks happens in the effective-gain useEffect below.
  useEffect(() => {
    watchingShareUserIdsMirror = watchingShareUserIds;
    screenShareAudioMutedIdsMirror = screenShareAudioMutedIds;
  }, [watchingShareUserIds, screenShareAudioMutedIds]);

  // Single source of truth for remote-audio output gain. Runs every time a
  // state that influences the effective-gain formula flips, plus on every
  // participantVolumes / screenShareVolumes subscriber ping. Keeps deafen,
  // watching, manual mute, and user volume all in one pass per pipeline.
  useEffect(() => {
    const recompute = () => {
      // JS-side auto-duck retired. The native audio-loopback addon
      // (modules/audio-loopback.ts on Windows) excludes our process tree
      // from the captured stream at OS level via WASAPI's
      // EXCLUDE_TARGET_PROCESS_TREE, so peers can't hear themselves
      // echoed back even when call audio is playing locally. Muting
      // remote-mic <audio> elements while capturing was the wrong fix —
      // it killed the user's ability to hear callers during a share.
      // Constant kept inline so the existing `else if (duck)` branch
      // below stays dead and `el.muted = isDeafened || duck` collapses
      // to just `isDeafened`.
      const duck = false;
      for (const pipeline of allPipelines()) {
        let g: number;
        if (isDeafened) {
          g = 0;
        } else if (pipeline.trackSource === 'screenshare') {
          const watching = watchingShareUserIds.has(pipeline.participantId);
          const manualMuted = screenShareAudioMutedIds.has(pipeline.participantId);
          g = watching && !manualMuted ? getScreenShareVolume(pipeline.participantId) : 0;
        } else if (duck) {
          g = 0;
        } else {
          g = getParticipantVolume(pipeline.participantId);
        }
        setPipelineGain(pipeline, g);
      }

      // Mirror the mute decision onto the HTMLAudioElement itself. When the
      // user has selected a custom output device, `audio.setSinkId(...)`
      // causes the element to play through that sink in PARALLEL to the
      // WebAudio graph (Chromium quirk) — gain=0 silences the WebAudio
      // path but the element keeps going. Setting `el.muted` covers that
      // direct-playback path. Volume slider already mirrors via
      // applyToAttachedElements in screenShareVolumes.ts; this is the
      // mute-equivalent.
      const shareEls = document.querySelectorAll<HTMLAudioElement>(
        'audio[data-track-source="screenshare"]',
      );
      shareEls.forEach((el) => {
        const pid = el.getAttribute('data-participant');
        if (!pid) return;
        const watching = watchingShareUserIds.has(pid);
        const manualMuted = screenShareAudioMutedIds.has(pid);
        el.muted = isDeafened || !watching || manualMuted;
      });
      // Same parallel-playback risk for microphone elements, but only
      // surfaces while we're capturing system audio for an outgoing share —
      // otherwise muting peers' voices locally would silence the call. The
      // duck-while-sharing flag ensures this only kicks in when we're
      // actively capturing system audio for publication.
      const micEls = document.querySelectorAll<HTMLAudioElement>(
        'audio[data-track-source="microphone"]',
      );
      micEls.forEach((el) => {
        el.muted = isDeafened || duck;
      });
    };
    recompute();
    const unsubP = subscribeParticipantVolumes(recompute);
    const unsubS = subscribeScreenShareVolumes(recompute);
    // Toggle of the duck-while-sharing checkbox needs to re-run gain
    // immediately, otherwise the user has to flip share off/on for the
    // change to land.
    const unsubSettings = subscribeScreenShareSettings(recompute);
    return () => {
      unsubP();
      unsubS();
      unsubSettings();
    };
  }, [
    isDeafened,
    watchingShareUserIds,
    screenShareAudioMutedIds,
    isCapturingSystemAudio,
    // `remoteParticipants` is a dep so the initial gain gets applied when a
    // brand new pipeline lands — attachTrack creates it asynchronously,
    // state flips, effect re-runs, gain updates.
    remoteParticipants,
  ]);

  const setAudioOutputDevice = useCallback(async (deviceId: string | null) => {
    updateAudioSettings({ outputDeviceId: deviceId });
    const sinkId = deviceId ?? '';
    // WebAudio path: route every pipeline's AudioContext at the chosen
    // sink. Feature-detects AudioContext.setSinkId (Chrome 115+) — older
    // runtimes silently keep the default sink.
    await setAllPipelinesSinkId(sinkId);
    // HTMLAudioElement fallback path: elements stay in the DOM for track
    // lifetime even when WebAudio takes over their output; keep setSinkId
    // in sync there so a runtime that didn't support createMediaElement-
    // Source still routes to the right device.
    const els = document.querySelectorAll<HTMLAudioElement>(
      'audio[data-livekit-track]',
    );
    for (const el of Array.from(els)) {
      if (typeof el.setSinkId !== 'function') continue;
      try {
        await el.setSinkId(sinkId);
      } catch (err: unknown) {
        console.warn('setSinkId on audio element failed', err);
      }
    }
    const r = roomRef.current;
    if (r) {
      try {
        await r.switchActiveDevice('audiooutput', sinkId || 'default');
      } catch (err: unknown) {
        console.warn('switchActiveDevice(audiooutput) failed', err);
      }
    }
  }, []);

  // Reset UI call-mode state when the call leaves any active phase so the next
  // call starts fresh at grid/unfocused.
  useEffect(() => {
    if (state.kind === 'idle' || state.kind === 'error') {
      setCallModeState('grid');
      setFocusedIdState(null);
    }
  }, [state.kind]);

  // Hold the screen awake while a call is live so long sessions don't get
  // dropped by display-sleep / OS power-save. Released the moment the call
  // ends or errors out.
  useEffect(() => {
    const callActive =
      state.kind === 'connected' ||
      state.kind === 'connecting' ||
      state.kind === 'reconnecting' ||
      state.kind === 'outgoing';
    void setCallWakeLock(callActive);
  }, [state.kind]);

  // Cinema mode = real OS fullscreen. The CSS overlay alone leaves the
  // Windows taskbar drawn on top, which kills the immersive feel; flipping
  // the OS-level fullscreen flag (via the Electron preload bridge) covers
  // it. Effect runs on every mode change so exit paths (Esc,
  // hangup-resets-to-grid, mode toggle) all restore the windowed state
  // automatically. Errors are swallowed inside setWindowFullscreen — the
  // call rejects on minimised / unfocused windows, both recoverable noise.
  useEffect(() => {
    void setWindowFullscreen(callMode === 'fullscreen');
  }, [callMode]);

  // Global Esc: drop out of fullscreen cinema back to grid while in an active
  // call. Doesn't hangup and doesn't fire when other dialogs would consume it.
  useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      if (e.key !== 'Escape') return;
      if (callMode !== 'fullscreen') return;
      if (
        state.kind !== 'connected' &&
        state.kind !== 'connecting' &&
        state.kind !== 'reconnecting'
      ) {
        return;
      }
      setCallModeState('grid');
    };
    window.addEventListener('keydown', onKey);
    return () => window.removeEventListener('keydown', onKey);
  }, [callMode, state.kind]);

  // Discord-style live-captions broadcaster — runs on the local mic while
  // we're connected, and ships interim/final transcripts on the LiveKit
  // DataChannel so peers can render them.
  useLiveCaptions({
    room,
    active: state.kind === 'connected' || state.kind === 'reconnecting',
    onLocalCaption: pushLocalCaption,
  });

  const value = useMemo<CallContextValue>(
    () => ({
      state,
      room,
      remoteParticipants,
      isMuted,
      isE2EEActive,
      isScreenSharing,
      isCameraEnabled,
      isDeafened,
      remoteDeafen,
      remoteMute,
      connectionQualities,
      callHostId,
      captions,
      pushLocalCaption,
      remoteScreenShares,
      lastCallConversationId,
      callMode,
      focusedId,
      startCall,
      joinActiveCall,
      acceptIncoming,
      rejectIncoming,
      hangup,
      toggleMute,
      toggleScreenShare,
      startScreenShare,
      stopScreenShare,
      toggleCamera,
      toggleDeafen,
      dismissLastCall,
      setCallMode,
      setFocusedId,
      setAudioInputDevice,
      setAudioOutputDevice,
      setVideoInputDevice,
      playSoundboard,
      stopSoundboard,
      activeSoundboardIds,
      setSoundboardMasterGain,
      setSoundboardMonitorGain,
      micError,
      clearMicError,
      retryMic,
      watchingShareUserIds,
      dismissedShareUserIds,
      watchShare,
      stopWatchingShare,
      dismissShare,
      screenShareAudioMutedIds,
      setScreenShareAudioMuted,
      outgoingShareAudioMuted,
      toggleOutgoingShareAudioMute,
      pendingIncoming,
      acceptPendingIncoming,
      rejectPendingIncoming,
    }),
    [
      state,
      room,
      remoteParticipants,
      isMuted,
      isE2EEActive,
      isScreenSharing,
      isCameraEnabled,
      isDeafened,
      remoteDeafen,
      remoteMute,
      connectionQualities,
      callHostId,
      captions,
      pushLocalCaption,
      remoteScreenShares,
      lastCallConversationId,
      callMode,
      focusedId,
      startCall,
      joinActiveCall,
      acceptIncoming,
      rejectIncoming,
      hangup,
      toggleMute,
      toggleScreenShare,
      startScreenShare,
      stopScreenShare,
      toggleCamera,
      toggleDeafen,
      dismissLastCall,
      setCallMode,
      setFocusedId,
      setAudioInputDevice,
      setAudioOutputDevice,
      setVideoInputDevice,
      playSoundboard,
      stopSoundboard,
      activeSoundboardIds,
      setSoundboardMasterGain,
      setSoundboardMonitorGain,
      micError,
      clearMicError,
      retryMic,
      watchingShareUserIds,
      dismissedShareUserIds,
      watchShare,
      stopWatchingShare,
      dismissShare,
      screenShareAudioMutedIds,
      setScreenShareAudioMuted,
      outgoingShareAudioMuted,
      toggleOutgoingShareAudioMute,
      pendingIncoming,
      acceptPendingIncoming,
      rejectPendingIncoming,
    ],
  );

  return <CallContext.Provider value={value}>{children}</CallContext.Provider>;
}

export function useCall(): CallContextValue {
  const ctx = useContext(CallContext);
  if (!ctx) throw new Error('useCall must be used inside <CallProvider>');
  return ctx;
}

// Shared flag so attachTrack (called from LiveKit event listeners, outside the
// React component) can apply the current deafen state to freshly-attached
// audio elements. Toggled by toggleDeafen in sync with the React state.
let deafenedActive = false;

// Same pattern for the "which shares is the user actively watching" set —
// used by attachTrack to decide whether a freshly-landed ScreenShareAudio
// track should start muted. Synced from React via a useEffect inside
// CallProvider.
let watchingShareUserIdsMirror: ReadonlySet<string> = new Set<string>();

// Discord-style: ScreenShareAudio tracks that arrived while the user
// hadn't clicked "Bildschirm anschauen" yet. We don't run track.attach()
// until they do — keeping these audio elements out of the DOM is more
// reliable than gating with WebAudio gain=0 (autoplay-suspended contexts
// + WebView2 quirks have leaked through gain=0 in the past). Keyed by
// participantId since one share publishes one audio track at a time.
interface DeferredShareAudio {
  track: RemoteTrack;
  publication: RemoteTrackPublication;
  participant: RemoteParticipant;
}
const deferredScreenAudio = new Map<string, Map<string, DeferredShareAudio>>();

function recordDeferredScreenAudio(
  track: RemoteTrack,
  publication: RemoteTrackPublication,
  participant: RemoteParticipant,
): void {
  const pid = participant.identity;
  const sid = track.sid;
  if (!pid || !sid) return;
  let inner = deferredScreenAudio.get(pid);
  if (!inner) {
    inner = new Map();
    deferredScreenAudio.set(pid, inner);
  }
  inner.set(sid, { track, publication, participant });
}

function removeDeferredScreenAudio(pid: string, sid?: string): void {
  const inner = deferredScreenAudio.get(pid);
  if (!inner) return;
  if (sid) {
    inner.delete(sid);
    if (inner.size === 0) deferredScreenAudio.delete(pid);
  } else {
    deferredScreenAudio.delete(pid);
  }
}

function flushDeferredScreenAudio(pid: string): void {
  const inner = deferredScreenAudio.get(pid);
  if (!inner) return;
  // Snapshot before iterating so attachTrack's defer check (which now
  // reads the freshly-updated mirror as "watching") doesn't try to
  // re-record the same entry mid-loop.
  const entries = [...inner.values()];
  deferredScreenAudio.delete(pid);
  for (const entry of entries) {
    attachTrack(entry.track, entry.publication, entry.participant);
  }
}

// Manual mute flags for screen-share audio, independent of the watching
// state. When a userId sits in here, their share-audio stays muted even
// after the user clicked "Bildschirm anschauen".
let screenShareAudioMutedIdsMirror: ReadonlySet<string> = new Set<string>();

async function broadcastPresence(
  room: Room,
  deafened: boolean,
  muted: boolean,
): Promise<void> {
  try {
    const payload = new TextEncoder().encode(
      JSON.stringify({ type: 'presence', deafened, muted }),
    );
    await room.localParticipant.publishData(payload, { reliable: true });
  } catch (err: unknown) {
    console.warn('broadcastPresence failed', err);
  }
}

function attachTrack(
  track: RemoteTrack,
  publication: RemoteTrackPublication,
  participant: RemoteParticipant,
): void {
  if (track.kind === Track.Kind.Audio) {
    const isScreenShareAudio =
      track.source === Track.Source.ScreenShareAudio ||
      publication.source === Track.Source.ScreenShareAudio;
    // Defer screen-share audio until the user explicitly opts in by
    // clicking "Bildschirm anschauen". Without this gate the audio
    // element is created on TrackSubscribed and — under autoplay-
    // suspended AudioContexts — can leak through the gain=0 silencer.
    // The track gets attached later inside watchShare via
    // flushDeferredScreenAudio.
    if (isScreenShareAudio && participant.identity) {
      if (!watchingShareUserIdsMirror.has(participant.identity)) {
        recordDeferredScreenAudio(track, publication, participant);
        return;
      }
    }
    const audio = track.attach();
    if (audio instanceof HTMLAudioElement) {
      audio.autoplay = true;
      audio.setAttribute('playsinline', 'true');
      audio.setAttribute('data-livekit-track', track.sid ?? '');
      // Always tag the kind so the gain-recompute can find every element of
      // either source. Without "microphone" tagged, the duck-while-sharing
      // logic can't reach the parallel direct-playback path that WebView2
      // keeps alive when audio.setSinkId is set on the element.
      audio.setAttribute(
        'data-track-source',
        isScreenShareAudio ? 'screenshare' : 'microphone',
      );
      // Diagnostic — surfaces source-tag mismatches between SDK versions.
      // If a remote participant publishes system-audio but the tag never
      // reaches us, `isScreenShareAudio` flips false and the watching
      // gate is bypassed; seeing this in the console tells us whether
      // the unwanted playback is a gating bug or a tagging mismatch.
      console.info('attachTrack:audio', {
        participant: participant.identity,
        trackSource: track.source,
        pubSource: publication.source,
        isScreenShareAudio,
        watching: participant.identity
          ? watchingShareUserIdsMirror.has(participant.identity)
          : null,
      });
      if (participant.identity) {
        audio.setAttribute('data-participant', participant.identity);
      }
      document.body.appendChild(audio);

      // Build the WebAudio pipeline so we have a single GainNode we can
      // drive past 100% (up to 200%). Setting `audio.volume` / `muted`
      // directly from here on is a no-op once the MediaElementSource
      // diverts the samples through the graph — every gating decision
      // flows through `setPipelineGain(effectiveGainFor(...))`.
      const trackSid = track.sid;
      if (trackSid && participant.identity) {
        const pipeline = createPipeline(audio, {
          trackSid,
          participantId: participant.identity,
          trackSource: isScreenShareAudio ? 'screenshare' : 'microphone',
        });
        if (pipeline) {
          setPipelineGain(pipeline, computeInitialEffectiveGain(pipeline));
        } else {
          // WebAudio unavailable — fall back to element-level volume so
          // the user at least hears something, even without 200% boost.
          audio.muted = false;
          audio.volume = isScreenShareAudio
            ? getScreenShareVolume(participant.identity)
            : getParticipantVolume(participant.identity);
          if (deafenedActive) audio.muted = true;
          else if (isScreenShareAudio) {
            const pid = participant.identity;
            const watching = watchingShareUserIdsMirror.has(pid);
            const manualMuted = screenShareAudioMutedIdsMirror.has(pid);
            audio.muted = !watching || manualMuted;
          }
        }
      }

      // Persist the user's chosen output device on the HTMLAudioElement
      // as a redundant guard — some WebView2 builds route the element
      // directly despite createMediaElementSource. When AudioContext
      // .setSinkId is available (see setAudioOutputDevice), that picks
      // up the same preference for the WebAudio graph.
      const sinkId = getAudioSettings().outputDeviceId;
      if (sinkId && typeof audio.setSinkId === 'function') {
        void audio.setSinkId(sinkId).catch((err: unknown) => {
          console.warn('setSinkId on attach failed', err);
        });
      }

      // Earlier builds muted mid-share peer joins here ("if we're capturing
      // system audio + duck flag → mute new mic <audio>"). Removed because
      // the native audio-loopback addon now does process-tree exclusion at
      // OS level and JS-side ducking only made the local user unable to
      // hear callers. Mid-share joins now just play normally.
    }
  }
  // Video is handled later in M2.6/M3 by a dedicated <video> element.
}

// Effective-gain formula in one place so both the initial attach and the
// runtime recompute stay consistent. Read the comment chain in attachTrack
// for the priority order.
function computeInitialEffectiveGain(pipeline: RemoteAudioPipeline): number {
  if (deafenedActive) return 0;
  if (pipeline.trackSource === 'screenshare') {
    const watching = watchingShareUserIdsMirror.has(pipeline.participantId);
    const manualMuted = screenShareAudioMutedIdsMirror.has(pipeline.participantId);
    if (!watching || manualMuted) return 0;
    return getScreenShareVolume(pipeline.participantId);
  }
  return getParticipantVolume(pipeline.participantId);
}

function detachTrack(
  track: RemoteTrack,
  publication: RemoteTrackPublication,
  participant: RemoteParticipant,
): void {
  if (track.kind === Track.Kind.Audio) {
    // Drop the deferred slot if this track was never attached. Safe to
    // call even on tracks that did get attached — the map lookup just
    // misses for those.
    const isScreenShareAudio =
      track.source === Track.Source.ScreenShareAudio ||
      publication.source === Track.Source.ScreenShareAudio;
    if (isScreenShareAudio && participant.identity) {
      removeDeferredScreenAudio(participant.identity, track.sid);
    }
    if (track.sid) destroyPipeline(track.sid);
    const els = track.detach();
    for (const el of els) {
      el.remove();
    }
  }
}

