// PhoneBLive — Variant B with real LiveKit PTT wiring.
// Layout/visuals mirror phone-b.jsx; usePttHold is swapped for useLivePtt which
// drives /api/livekit/token + /api/ptt/claim|release + LiveKit publish.
// Receive UX: when remote participants are speaking (ActiveSpeakersChanged),
// a top toast names them and their mapped team pin pulses red.

// ─────────────────────────────────────────────────────────────
// Query / API helpers
// ─────────────────────────────────────────────────────────────
function parseLiveQuery() {
  const u = new URL(window.location.href);
  return {
    room: u.searchParams.get('room') || 'tenant1:main',
    user: u.searchParams.get('user') || 'user-' + Math.random().toString(36).slice(2, 8),
    ch:   u.searchParams.get('ch')   || 'ch01',
    role: u.searchParams.get('role') || 'publisher',
    lang: u.searchParams.get('lang') || 'ja',
  };
}

function trackPrefix(name) {
  if (!name) return null;
  const i = name.indexOf('/');
  return i > 0 ? name.slice(0, i) : null;
}

function shouldSubscribe(trackName, currentCh) {
  const p = trackPrefix(trackName);
  if (p === 'broadcast') return true;
  if (p === currentCh) return true;
  return false;
}

async function fetchLiveToken({ room, identity, ch, role }) {
  const res = await fetch(`${window.PTT_CONFIG.apiBase}/livekit/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
    body: JSON.stringify({ room, identity, ch, role }),
  });
  if (!res.ok) throw new Error(`token API ${res.status}: ${await res.text()}`);
  return res.json();
}

async function postLiveJson(path, body) {
  const res = await fetch(`${window.PTT_CONFIG.apiBase}${path}`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
    body: JSON.stringify(body),
  });
  const data = await res.json().catch(() => ({}));
  return { ok: res.ok, status: res.status, data };
}

async function preacquireLiveMic() {
  try {
    // 既に許可状態が確定しているなら getUserMedia を呼ばない（＝マイクを起動しない）。
    // Permissions API は「マイクを起動せずに」許可状態だけを確認できる。
    // 'prompt'（初回）のときだけ getUserMedia でダイアログを出す（このときだけ一瞬オン）。
    if (navigator.permissions && navigator.permissions.query) {
      try {
        const st = await navigator.permissions.query({ name: 'microphone' });
        if (st.state === 'granted') return true;   // 許可済み → 起動不要
        if (st.state === 'denied') return false;   // 拒否済み → 起動せず警告
      } catch (_) { /* Safari 等は microphone 未対応 → 下の getUserMedia にフォールバック */ }
    }
    const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
    stream.getTracks().forEach(t => t.stop());
    return true;
  } catch (e) {
    // 拒否 / デバイス無し等。呼び出し側で警告 UI を出す。
    return false;
  }
}

// Map a remote identity to a TEAMS pin (skip 'cmd' since this device is command-side).
function teamForIdentity(identity) {
  const ids = TEAMS.filter(t => t.kind !== 'cmd').map(t => t.id);
  let hash = 0;
  for (let i = 0; i < identity.length; i++) hash = (hash * 31 + identity.charCodeAt(i)) | 0;
  return ids[Math.abs(hash) % ids.length];
}

// Real-map team positions around 東京都港区芝5丁目 (approximate, demo only).
const LIVE_FIRE_LATLNG = [35.6486, 139.7497];
const LIVE_TEAMS = [
  { id: 't1',    label: '第1小隊', lat: 35.6488, lng: 139.7493, color: '#2B7FFF', kind: 'unit'  },
  { id: 't2',    label: '第2小隊', lat: 35.6489, lng: 139.7502, color: '#2B7FFF', kind: 'unit'  },
  { id: 't3',    label: '第3小隊', lat: 35.6483, lng: 139.7499, color: '#2B7FFF', kind: 'unit'  },
  { id: 'cmd',   label: '指揮隊',  lat: 35.6486, lng: 139.7497, color: '#FFC64D', kind: 'cmd'   },
  { id: 'drone', label: 'ドローン', lat: 35.6491, lng: 139.7497, color: '#22D3EE', kind: 'drone' },
];

// ─────────────────────────────────────────────────────────────
// LeafletDarkMap — GSI standard tiles with CSS dark inversion
// ─────────────────────────────────────────────────────────────
function teamMarkerHtml(t, speaking) {
  const isDrone = t.kind === 'drone';
  const isCmd = t.kind === 'cmd';
  const inner = isDrone ? '◈' : isCmd ? '★' : t.label.replace(/[^0-9]/g, '') || '•';
  const pinColor = isCmd ? '#0D1116' : '#fff';
  return `
    <div class="team-marker-pin ${speaking ? 'speaking' : ''}" style="background:${t.color};color:${pinColor};">
      ${inner}
    </div>
    <div class="team-marker-label ${speaking ? 'speaking' : ''}">${t.label}</div>
  `;
}

function LeafletDarkMap({ speakingTeams }) {
  const mapElRef = React.useRef(null);
  const mapRef = React.useRef(null);
  const markersRef = React.useRef({});

  // Init map once
  React.useEffect(() => {
    if (!mapElRef.current || mapRef.current) return;

    const map = window.L.map(mapElRef.current, {
      center: LIVE_FIRE_LATLNG,
      zoom: 18,
      zoomControl: false,
      attributionControl: true,
      scrollWheelZoom: false,
      doubleClickZoom: false,
      keyboard: false,
    });

    window.L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
      maxZoom: 19,
      attribution: '&copy; <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">OpenStreetMap</a> contributors',
    }).addTo(map);

    // Fire marker
    const fireIcon = window.L.divIcon({
      className: '',
      html: '<div class="fire-marker"><div class="fire-dot">🔥</div></div>',
      iconSize: [36, 36],
      iconAnchor: [18, 18],
    });
    window.L.marker(LIVE_FIRE_LATLNG, { icon: fireIcon, interactive: false }).addTo(map);

    // Team markers
    LIVE_TEAMS.forEach(t => {
      const icon = window.L.divIcon({
        className: '',
        html: teamMarkerHtml(t, false),
        iconSize: [28, 28],
        iconAnchor: [14, 14],
      });
      const marker = window.L.marker([t.lat, t.lng], { icon, interactive: false }).addTo(map);
      markersRef.current[t.id] = { marker, t };
    });

    mapRef.current = map;

    return () => {
      try { map.remove(); } catch (e) {}
      mapRef.current = null;
      markersRef.current = {};
    };
  }, []);

  // Update marker speaking state when set changes
  React.useEffect(() => {
    Object.entries(markersRef.current).forEach(([id, { marker, t }]) => {
      const speaking = speakingTeams.has(id);
      const icon = window.L.divIcon({
        className: '',
        html: teamMarkerHtml(t, speaking),
        iconSize: [28, 28],
        iconAnchor: [14, 14],
      });
      marker.setIcon(icon);
    });
  }, [speakingTeams]);

  return <div ref={mapElRef} style={{ width: '100%', height: '100%' }} />;
}

// ─────────────────────────────────────────────────────────────
// useLiveRoom — connect, subscribe-filter by ch, expose remote audio + active speakers
// ─────────────────────────────────────────────────────────────
function useLiveRoom(cfg) {
  const [status, setStatus] = React.useState('idle'); // idle|connecting|connected|error
  const [errorMsg, setErrorMsg] = React.useState(null);
  const [activeRemotes, setActiveRemotes] = React.useState([]); // [{identity, team}] 音声 level 連動（バナー/ピン用）
  const [publishingRemotes, setPublishingRemotes] = React.useState([]); // publication 連動（グレーアウト用）
  const roomRef = React.useRef(null);
  const currentChRef = React.useRef(cfg.ch);
  const attachedRef = React.useRef({}); // {sid: audioEl}
  // iOS Safari の autoplay ブロック対策：play() が拒否された element を積む
  // 次の user gesture で全部 retry
  const pendingPlayRef = React.useRef(new Set());
  const [audioUnlockNeeded, setAudioUnlockNeeded] = React.useState(false);
  // identity -> 最後に ActiveSpeakersChanged 内で見た時刻（ms）。
  // 2 秒以上 refresh されない identity は「発話停止」とみなして強制クリアする
  // （LiveKit の ActiveSpeakersChanged は停止遷移時に取りこぼしがあるため）
  const lastSeenSpeakerRef = React.useRef(new Map());

  // audio.play() を呼んで、reject されたら queue
  const tryPlay = React.useCallback((el) => {
    if (!el) return;
    const p = el.play();
    if (p && typeof p.catch === 'function') {
      p.catch((err) => {
        console.warn('[LK] audio.play() blocked, queueing for user gesture', err?.name);
        pendingPlayRef.current.add(el);
        setAudioUnlockNeeded(true);
      });
    }
  }, []);

  React.useEffect(() => {
    let cancelled = false;
    let room = null;
    (async () => {
      try {
        setStatus('connecting');
        const tokenData = await fetchLiveToken({
          room: cfg.room, identity: cfg.user,
          ch: cfg.role === 'broadcast' ? null : cfg.ch,
          role: cfg.role,
        });
        if (cancelled) return;

        const { Room, RoomEvent } = window.LivekitClient;
        room = new Room({ adaptiveStream: true, dynacast: true });
        roomRef.current = room;

        const trackInScope = (pub) => shouldSubscribe(pub.trackName, currentChRef.current);
        const addPublisher = (identity) => {
          if (identity === cfg.user) return;
          setPublishingRemotes(prev => {
            if (prev.some(r => r.identity === identity)) return prev;
            return [...prev, { identity, team: teamForIdentity(identity) }];
          });
        };
        const removePublisher = (identity) => {
          setPublishingRemotes(prev => prev.filter(r => r.identity !== identity));
        };

        const applyFilter = () => {
          const publishers = [];
          room.remoteParticipants.forEach((rp) => {
            rp.trackPublications.forEach((pub) => {
              const want = trackInScope(pub);
              if (pub.isSubscribed !== want) pub.setSubscribed(want);
              if (pub.kind === 'audio' && want && rp.identity !== cfg.user) {
                publishers.push({ identity: rp.identity, team: teamForIdentity(rp.identity) });
              }
            });
          });
          setPublishingRemotes(publishers);
        };

        // 自分自身を弾く判定（reference + identity の両方で守る、参照比較が真の正解）
        const isSelf = (p) => !p || p === room.localParticipant || p.identity === cfg.user;

        room.on(RoomEvent.TrackPublished, (pub, participant) => {
          if (isSelf(participant)) return;
          if (trackInScope(pub)) pub.setSubscribed(true);
          if (pub.kind === 'audio' && trackInScope(pub) && participant) addPublisher(participant.identity);
        });
        room.on(RoomEvent.TrackSubscribed, (track, pub, participant) => {
          if (track.kind !== 'audio') return;
          if (isSelf(participant)) {
            console.warn('[LK] TrackSubscribed for SELF — skipping attach (self-echo guard)', { identity: participant?.identity, cfgUser: cfg.user });
            return;
          }
          const el = track.attach();
          el.autoplay = true;
          el.playsInline = true;
          el.muted = false;
          el.style.display = 'none';
          document.body.appendChild(el);
          attachedRef.current[pub.trackSid] = el;
          tryPlay(el); // iOS Safari の autoplay ブロック対策
        });
        room.on(RoomEvent.TrackUnsubscribed, (track, pub) => {
          const el = attachedRef.current[pub.trackSid];
          if (el) { try { el.remove(); } catch (e) {} delete attachedRef.current[pub.trackSid]; }
        });
        room.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
          const now = Date.now();
          const remotes = speakers
            // 自分自身を弾く（reference + identity の両方で守る）
            .filter(p => p !== room.localParticipant && p.identity !== cfg.user)
            .map(p => ({ identity: p.identity, team: teamForIdentity(p.identity) }));
          remotes.forEach(r => lastSeenSpeakerRef.current.set(r.identity, now));
          setActiveRemotes(remotes);
        });
        // PTT release / mute / 退室で確実に「受信中」と「publish 中」を外す
        const dropSpeaker = (identity) => {
          lastSeenSpeakerRef.current.delete(identity);
          setActiveRemotes(prev => prev.filter(r => r.identity !== identity));
        };
        room.on(RoomEvent.TrackUnpublished, (pub, participant) => {
          if (pub && pub.kind === 'audio' && participant) {
            dropSpeaker(participant.identity);
            removePublisher(participant.identity);
          }
        });
        room.on(RoomEvent.TrackMuted, (pub, participant) => {
          if (pub && pub.kind === 'audio' && participant) {
            dropSpeaker(participant.identity);
            removePublisher(participant.identity);
          }
        });
        room.on(RoomEvent.ParticipantDisconnected, (participant) => {
          if (participant) {
            dropSpeaker(participant.identity);
            removePublisher(participant.identity);
          }
        });
        room.on(RoomEvent.Disconnected, () => setStatus('idle'));
        room.on(RoomEvent.Reconnecting, () => setStatus('connecting'));
        room.on(RoomEvent.Reconnected, () => {
          setStatus('connected');
          // 再接続後の取りこぼし対策：subscribed audio が attach されていなければ再 attach
          room.remoteParticipants.forEach((rp) => {
            rp.trackPublications.forEach((pub) => {
              if (pub.isSubscribed && pub.track && pub.track.kind === 'audio'
                  && !attachedRef.current[pub.trackSid]) {
                try {
                  const el = pub.track.attach();
                  el.autoplay = true;
                  el.style.display = 'none';
                  document.body.appendChild(el);
                  attachedRef.current[pub.trackSid] = el;
                  console.log('[LK] re-attached audio after Reconnected', pub.trackSid);
                } catch (e) {}
              }
            });
          });
        });

        await room.connect(tokenData.url, tokenData.token, { autoSubscribe: false });
        if (cancelled) { await room.disconnect(); return; }
        setStatus('connected');
        applyFilter();
      } catch (e) {
        if (cancelled) return;
        setErrorMsg(String(e && e.message ? e.message : e));
        setStatus('error');
      }
    })();
    return () => {
      cancelled = true;
      if (roomRef.current) { roomRef.current.disconnect(); roomRef.current = null; }
      Object.values(attachedRef.current).forEach(el => { try { el.remove(); } catch (e) {} });
      attachedRef.current = {};
    };
  }, [cfg.room, cfg.user, cfg.ch, cfg.role]);

  // バックグラウンド復帰時の復旧ハンドラ：iOS Safari は WebRTC をサスペンドするため
  // 1. ゴーストな activeRemotes をクリア
  // 2. subscribed audio を再 attach + play() 再試行
  // 3. それでも room が disconnected のままなら 5 秒後に page reload で最終救済
  React.useEffect(() => {
    let reloadTimer = null;
    const onVisibility = () => {
      if (document.visibilityState !== 'visible') {
        if (reloadTimer) { clearTimeout(reloadTimer); reloadTimer = null; }
        return;
      }
      const room = roomRef.current;
      if (!room) return;
      console.log('[LK] visibilitychange visible, room.state:', room.state);

      // 1) ゴーストクリア
      lastSeenSpeakerRef.current.clear();
      setActiveRemotes([]);

      // 2) 既存 subscribed audio の再 attach 検証 + play() 再試行
      room.remoteParticipants.forEach((rp) => {
        rp.trackPublications.forEach((pub) => {
          if (pub.isSubscribed && pub.track && pub.track.kind === 'audio'
              && !attachedRef.current[pub.trackSid]) {
            try {
              const el = pub.track.attach();
              el.autoplay = true;
              el.playsInline = true;
              el.style.display = 'none';
              document.body.appendChild(el);
              attachedRef.current[pub.trackSid] = el;
              tryPlay(el);
              console.log('[LK] re-attached audio on visibility resume', pub.trackSid);
            } catch (e) {}
          }
        });
      });
      // 既存の attached element にも play() リトライ（iOS 復帰時に一時停止していることがあるため）
      Object.values(attachedRef.current).forEach(el => tryPlay(el));

      // 3) 5 秒後も disconnected のままなら page reload
      if (reloadTimer) clearTimeout(reloadTimer);
      reloadTimer = setTimeout(() => {
        const r = roomRef.current;
        if (r && r.state !== 'connected') {
          console.warn('[LK] room not recovered after 5s, reloading page', r.state);
          window.location.reload();
        }
      }, 5000);
    };
    document.addEventListener('visibilitychange', onVisibility);
    return () => {
      document.removeEventListener('visibilitychange', onVisibility);
      if (reloadTimer) clearTimeout(reloadTimer);
    };
  }, [tryPlay]);

  // user gesture（pointerdown/touchstart/click のいずれか）で pending audio を一括 unlock
  React.useEffect(() => {
    const drain = () => {
      if (pendingPlayRef.current.size === 0) return;
      console.log('[LK] user gesture detected, draining', pendingPlayRef.current.size, 'pending audio elements');
      const els = Array.from(pendingPlayRef.current);
      pendingPlayRef.current.clear();
      setAudioUnlockNeeded(false);
      els.forEach(el => {
        const p = el.play();
        if (p && typeof p.catch === 'function') p.catch(() => {});
      });
    };
    const opts = { capture: true, passive: true };
    document.addEventListener('pointerdown', drain, opts);
    document.addEventListener('touchstart', drain, opts);
    document.addEventListener('click', drain, opts);
    return () => {
      document.removeEventListener('pointerdown', drain, opts);
      document.removeEventListener('touchstart', drain, opts);
      document.removeEventListener('click', drain, opts);
    };
  }, []);

  // activeRemotes タイムアウト GC：2 秒間 ActiveSpeakersChanged で refresh されない identity は
  // 「発話停止イベントが取りこぼされた」とみなして強制クリアする最終防衛線
  React.useEffect(() => {
    const id = setInterval(() => {
      const now = Date.now();
      const expired = [];
      lastSeenSpeakerRef.current.forEach((ts, identity) => {
        if (now - ts > 2000) expired.push(identity);
      });
      if (expired.length > 0) {
        expired.forEach(id => lastSeenSpeakerRef.current.delete(id));
        setActiveRemotes(prev => prev.filter(r => !expired.includes(r.identity)));
      }
    }, 500);
    return () => clearInterval(id);
  }, []);

  return { status, errorMsg, activeRemotes, publishingRemotes, audioUnlockNeeded, roomRef, currentChRef };
}

// ─────────────────────────────────────────────────────────────
// useLivePtt — claim → publishTrack → release. Mirrors /ptt-test usePtt.
// ─────────────────────────────────────────────────────────────
function useLivePtt({ cfg, roomRef, currentChRef, status }) {
  const [holding, setHolding] = React.useState(false);
  const [holdSec, setHoldSec] = React.useState(0);
  const [pttError, setPttError] = React.useState(null);

  const claimIdRef = React.useRef(null);
  const publishedTrackRef = React.useRef(null);
  const heartbeatRef = React.useRef(null);
  const tickRef = React.useRef(null);
  const holdStartRef = React.useRef(null);
  const busyRef = React.useRef(false);
  // begin() の async 進行中に end() が呼ばれた場合、publish 完了後に即 release するための flag
  const pendingEndRef = React.useRef(false);

  // Lightweight wave samples for the UI bar (purely cosmetic).
  const [waveSamples, setWaveSamples] = React.useState([]);
  const waveRef = React.useRef(null);

  const begin = React.useCallback(async () => {
    if (busyRef.current) return;
    if (status !== 'connected' || !roomRef.current) return;
    busyRef.current = true;
    pendingEndRef.current = false;
    setPttError(null);
    setHolding(true);

    const room = roomRef.current;
    const ch = currentChRef.current;
    const isBroadcast = cfg.role === 'broadcast';

    try {
      if (!isBroadcast) {
        const res = await postLiveJson('/ptt/claim', { room: cfg.room, ch, identity: cfg.user });
        if (!res.ok || !res.data.granted) {
          setPttError(`CH 使用中: ${res.data.holder || '?'}`);
          setHolding(false);
          busyRef.current = false;
          return;
        }
        claimIdRef.current = res.data.claim_id;
      }
      // claim 取得後に既に end() が来ていれば、ここで畳む
      if (pendingEndRef.current) {
        pendingEndRef.current = false;
        if (claimIdRef.current && !isBroadcast) {
          postLiveJson('/ptt/release', { room: cfg.room, ch, claim_id: claimIdRef.current }).catch(() => {});
          claimIdRef.current = null;
        }
        setHolding(false);
        busyRef.current = false;
        return;
      }
      const { createLocalAudioTrack } = window.LivekitClient;
      const track = await createLocalAudioTrack({
        echoCancellation: true,
        noiseSuppression: true,
        autoGainControl: true,
      });
      // マイク取得後にも end() チェック
      if (pendingEndRef.current) {
        pendingEndRef.current = false;
        try { track.stop(); } catch (e) {}
        if (claimIdRef.current && !isBroadcast) {
          postLiveJson('/ptt/release', { room: cfg.room, ch, claim_id: claimIdRef.current }).catch(() => {});
          claimIdRef.current = null;
        }
        setHolding(false);
        busyRef.current = false;
        return;
      }
      const trackName = isBroadcast ? `broadcast/${cfg.user}` : `${ch}/${cfg.user}`;
      console.log('[LK] about to publishTrack', { trackName, identity: cfg.user, localParticipantIdentity: room.localParticipant.identity });
      await room.localParticipant.publishTrack(track, { name: trackName });
      publishedTrackRef.current = track;
      // 公開直後にページ上の audio 要素一覧をダンプ（自己エコー診断）
      try {
        const audios = Array.from(document.querySelectorAll('audio,video'));
        console.log('[LK] DOM audio/video after publish', audios.length, audios.map(a => ({
          tag: a.tagName, paused: a.paused, muted: a.muted, srcObject: !!a.srcObject,
          tracks: a.srcObject ? a.srcObject.getTracks().map(t => ({ kind: t.kind, label: t.label, enabled: t.enabled })) : null
        })));
      } catch (e) {}
      // publish 完了後にも end() チェック → 即 unpublish
      if (pendingEndRef.current) {
        pendingEndRef.current = false;
        try { await room.localParticipant.unpublishTrack(track, true); } catch (e) {}
        try { track.stop(); } catch (e) {}
        publishedTrackRef.current = null;
        if (claimIdRef.current && !isBroadcast) {
          postLiveJson('/ptt/release', { room: cfg.room, ch, claim_id: claimIdRef.current }).catch(() => {});
          claimIdRef.current = null;
        }
        setHolding(false);
        busyRef.current = false;
        return;
      }

      if (!isBroadcast) {
        heartbeatRef.current = setInterval(async () => {
          if (!claimIdRef.current) return;
          await postLiveJson('/ptt/claim', {
            room: cfg.room, ch, identity: cfg.user, claim_id: claimIdRef.current,
          });
        }, 1000);
      }

      holdStartRef.current = performance.now();
      tickRef.current = setInterval(() => {
        setHoldSec((performance.now() - holdStartRef.current) / 1000);
      }, 100);

      // Cosmetic waveform (not tied to actual mic level — we keep payload light)
      waveRef.current = setInterval(() => {
        setWaveSamples(prev => {
          const next = [...prev, 0.3 + Math.random() * 0.7].slice(-24);
          return next;
        });
      }, 80);
    } catch (e) {
      setPttError(String(e && e.message ? e.message : e));
      setHolding(false);
      busyRef.current = false;
      if (claimIdRef.current && cfg.role !== 'broadcast') {
        await postLiveJson('/ptt/release', { room: cfg.room, ch, claim_id: claimIdRef.current })
          .catch(() => {});
        claimIdRef.current = null;
      }
    }
  }, [cfg, roomRef, currentChRef, status]);

  const end = React.useCallback(async () => {
    if (!busyRef.current) return;
    // publish 完了前なら、begin() の async 経路に終了を予約させる（race ガード）
    if (!publishedTrackRef.current) {
      pendingEndRef.current = true;
      return;
    }
    const room = roomRef.current;
    const ch = currentChRef.current;
    const isBroadcast = cfg.role === 'broadcast';

    if (heartbeatRef.current) { clearInterval(heartbeatRef.current); heartbeatRef.current = null; }
    if (tickRef.current) { clearInterval(tickRef.current); tickRef.current = null; }
    if (waveRef.current) { clearInterval(waveRef.current); waveRef.current = null; }

    if (publishedTrackRef.current && room) {
      try { await room.localParticipant.unpublishTrack(publishedTrackRef.current, true); } catch (e) {}
      try { publishedTrackRef.current.stop(); } catch (e) {}
      publishedTrackRef.current = null;
    }
    if (claimIdRef.current && !isBroadcast) {
      postLiveJson('/ptt/release', { room: cfg.room, ch, claim_id: claimIdRef.current })
        .catch(() => {});
      claimIdRef.current = null;
    }
    setHolding(false);
    setHoldSec(0);
    setWaveSamples([]);
    busyRef.current = false;
  }, [cfg, roomRef, currentChRef]);

  return { holding, holdSec, waveSamples, pttError, begin, end };
}

// ─────────────────────────────────────────────────────────────
// TeamPinLive — TeamPin plus optional speaking halo
// ─────────────────────────────────────────────────────────────
function TeamPinLive({ t, lang, dark, animate, tick, speaking }) {
  const drift = animate ? {
    x: Math.sin((tick / 600) + t.id.length) * 1.2,
    y: Math.cos((tick / 700) + t.id.length * 2) * 1.2,
  } : { x: 0, y: 0 };
  const label = lang === 'en' ? t.labelEn : t.label;
  const isDrone = t.kind === 'drone';
  const isCmd = t.kind === 'cmd';
  return (
    <div style={{
      position: 'absolute',
      left: `calc(${t.x + drift.x}% - 14px)`,
      top: `calc(${t.y + drift.y}% - 14px)`,
      transition: 'left 600ms linear, top 600ms linear',
    }}>
      {speaking && (
        <span style={{
          position: 'absolute', left: '50%', top: '50%',
          width: 28, height: 28, borderRadius: 999,
          border: '2px solid #EF4444',
          animation: 'speakerHalo 1.1s ease-out infinite',
          pointerEvents: 'none',
        }} />
      )}
      <div style={{
        width: 28, height: 28, borderRadius: 999,
        background: t.color,
        border: `2px solid ${dark ? '#0D1116' : '#fff'}`,
        boxShadow: speaking
          ? `0 0 0 3px #EF444466, 0 2px 6px rgba(0,0,0,0.6)`
          : `0 0 0 2px ${t.color}44, 0 2px 6px rgba(0,0,0,0.5)`,
        display: 'flex', alignItems: 'center', justifyContent: 'center',
        fontFamily: 'Inter, sans-serif', fontWeight: 800, fontSize: 11,
        color: isCmd ? '#0D1116' : '#fff',
        transition: 'box-shadow 200ms',
      }}>
        {isDrone ? '◈' : isCmd ? '★' : label.replace(/[^0-9]/g, '') || '•'}
      </div>
      <div style={{
        position: 'absolute', top: 30, left: '50%', transform: 'translateX(-50%)',
        background: speaking ? 'rgba(239,68,68,0.95)' : (dark ? 'rgba(13,17,22,0.85)' : 'rgba(255,255,255,0.92)'),
        color: speaking ? '#fff' : (dark ? '#E6ECF2' : '#0D1116'),
        fontFamily: 'Inter, sans-serif', fontSize: 9, fontWeight: 700,
        padding: '2px 5px', borderRadius: 2, whiteSpace: 'nowrap',
        border: speaking ? '1px solid #EF4444' : (dark ? '1px solid #222A33' : '1px solid #D7DCE2'),
      }}>
        {label}
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// PhoneBLive — main component
// ─────────────────────────────────────────────────────────────
function PhoneBLive({ width, height, chrome = 'phone' }) {
  const cfg = React.useMemo(parseLiveQuery, []);
  const lang = cfg.lang === 'en' ? 'en' : 'ja';
  const dark = true;

  const [tab, setTab] = React.useState('map');
  const [elapsed, setElapsed] = React.useState(33 * 60 + 55);
  const [cameraActive, setCameraActive] = React.useState(false);
  const [sosStep, setSosStep] = React.useState('idle');
  const [tick, setTick] = React.useState(0);
  // 音声有効化ゲート：起動時に必ず1回ユーザータップを取得する。
  // iOS/Safari の autoplay ブロックを「受信して初めて気づく」のではなく
  // 受信前に解除しておくことで「タップしないと声が聞こえない」事故を防ぐ。
  const [audioStarted, setAudioStarted] = React.useState(false);
  // マイク許可が拒否された場合の警告表示。発話できない致命的状態なので明示する。
  const [micDenied, setMicDenied] = React.useState(false);
  const now = useClock();

  React.useEffect(() => {
    const id = setInterval(() => setElapsed(e => e + 1), 1000);
    return () => clearInterval(id);
  }, []);
  React.useEffect(() => {
    let id;
    const loop = () => { setTick(t => t + 16); id = requestAnimationFrame(loop); };
    id = requestAnimationFrame(loop);
    return () => cancelAnimationFrame(id);
  }, []);

  const { status, errorMsg, activeRemotes, publishingRemotes, audioUnlockNeeded, roomRef, currentChRef } = useLiveRoom(cfg);

  // ?debug=net で NetLogger HUD + サーバログ
  const debugNet = new URLSearchParams(window.location.search).get('debug') === 'net';
  const netStats = window.useNetLogger ? window.useNetLogger({
    enabled: debugNet, pageLabel: 'phone-b-live', roomRef, cfgUser: cfg.user,
  }) : null;
  const ptt = useLivePtt({ cfg, roomRef, currentChRef, status });

  // 「受信中」表示は publishingRemotes（TrackPublished/Unpublished 確定イベントベース）を主軸に、
  // activeRemotes（音声 level 連動、iOS では取りこぼし多い）を補助で union する
  const _unionMap = new Map();
  publishingRemotes.forEach(r => _unionMap.set(r.identity, r));
  activeRemotes.forEach(r => { if (!_unionMap.has(r.identity)) _unionMap.set(r.identity, r); });
  const _unionList = Array.from(_unionMap.values());
  const speakingTeams = new Set(_unionList.map(r => r.team));
  const speakerNames = _unionList.map(r => r.identity);
  // グレーアウト用：相手が PTT 押している間（publication 連動）
  const othersPublishingNames = publishingRemotes.map(r => r.identity);

  const s = STRINGS[lang];
  const accent = '#FF7A1C';
  const accent2 = '#22D3EE';
  const bg = '#0A1220';
  const surface = '#0F1A2C';
  const surface2 = '#14223A';
  const text = '#E6ECF2';
  const muted = '#7C8BA3';
  const hairline = '#1C2A44';

  const tabs = [
    { id: 'map', label: s.map },
    { id: 'video', label: s.video },
    { id: 'chat', label: s.chat },
  ];

  const sosConfirm = () => {
    setSosStep('sending');
    setTimeout(() => setSosStep('idle'), 2400);
  };

  // ゲートのタップ＝確実な user gesture。
  // ① LiveKit 標準の startAudio() で再生を解禁、② マイク許可を先取り
  // （getUserMedia は user gesture 内で呼ぶ必要があり、ここが最適。初回 PTT 押下
  //   まで待たず、アクセス直後に許可ダイアログを出して granted 済みにする）、
  // ③ pending audio は document の gesture リスナが drain、④ ゲートを閉じる。
  const startAudioGate = () => {
    try {
      const p = roomRef.current?.startAudio?.();
      if (p && typeof p.catch === 'function') p.catch(() => {});
    } catch (_) { /* startAudio 非対応バージョンは無視（gesture だけで十分） */ }
    // gesture と同じ tick 内で getUserMedia を発火（iOS Safari の要件）。
    // 取得後すぐ stop するので録音インジケータは残らず、許可だけが永続化される。
    // 拒否されたら発話不可なので警告を出す（許可されたら警告解除）。
    preacquireLiveMic().then(ok => setMicDenied(!ok));
    setAudioStarted(true);
  };

  const statusPill = (() => {
    if (status === 'connected') return { txt: lang === 'ja' ? '出動中' : 'ACTIVE', color: accent, dot: accent };
    if (status === 'connecting') return { txt: lang === 'ja' ? '接続中' : 'CONNECTING', color: accent2, dot: accent2 };
    if (status === 'error') return { txt: lang === 'ja' ? '未接続' : 'OFFLINE', color: '#EF4444', dot: '#EF4444' };
    return { txt: lang === 'ja' ? '待機' : 'IDLE', color: muted, dot: muted };
  })();

  const isFull = chrome === 'full';
  const containerStyle = isFull
    ? { width: '100%', height: '100vh' }
    : { width, height, borderRadius: 40, outline: `1px solid ${hairline}` };

  return (
    <div style={{
      ...containerStyle,
      background: bg, position: 'relative',
      overflow: 'hidden', display: 'flex', flexDirection: 'column',
      fontFamily: 'Inter, sans-serif',
    }}>
      {!isFull && <StatusBar color={text} />}

      {/* Header */}
      <div style={{
        padding: '6px 18px 14px',
        background: `linear-gradient(180deg, ${surface} 0%, ${bg} 100%)`,
        borderBottom: `1px solid ${hairline}`,
      }}>
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
          <div>
            <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
              <div style={{
                width: 28, height: 28, borderRadius: 8,
                background: `linear-gradient(135deg, ${accent}, #E6352A)`,
                display: 'flex', alignItems: 'center', justifyContent: 'center',
                color: '#fff', fontSize: 15, fontWeight: 900, letterSpacing: -0.5,
                boxShadow: `0 4px 10px ${accent}66`,
              }}>119</div>
              <div>
                <div style={{ fontSize: 9, color: muted, letterSpacing: 1.2, textTransform: 'uppercase', fontWeight: 700 }}>
                  {s.appTitle}
                </div>
                <div style={{ fontSize: 13, fontWeight: 800, color: text, marginTop: -1, fontFamily: 'JetBrains Mono, monospace', letterSpacing: 0.3 }}>
                  DATA119
                </div>
              </div>
            </div>
          </div>
          <div style={{ textAlign: 'right' }}>
            <div style={{
              display: 'inline-flex', alignItems: 'center', gap: 6,
              padding: '3px 8px', borderRadius: 999,
              background: `${statusPill.color}1f`, border: `1px solid ${statusPill.color}66`,
              color: statusPill.color, fontSize: 10, fontWeight: 800, letterSpacing: 0.6,
            }}>
              <span style={{ width: 6, height: 6, borderRadius: 999, background: statusPill.dot, animation: 'pulseDot 1.2s infinite' }} />
              {statusPill.txt}
            </div>
            <div style={{ marginTop: 4, fontSize: 10, color: muted, fontFamily: 'JetBrains Mono, monospace' }}>
              {fmtDateTime(now, lang)}
            </div>
          </div>
        </div>

        {/* Elapsed / units / type strip */}
        <div style={{
          marginTop: 12,
          display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 8,
        }}>
          {[
            { lbl: s.elapsed, val: fmtElapsed(elapsed), color: accent2 },
            { lbl: s.dispatched, val: s.units.split(' ')[0], color: text, sub: s.units.split(' ').slice(1).join(' ') },
            { lbl: lang === 'ja' ? '種別' : 'Type', val: s.incidentType, color: accent },
          ].map((x, i) => (
            <div key={i} style={{
              background: surface, border: `1px solid ${hairline}`, borderRadius: 6, padding: '6px 8px',
            }}>
              <div style={{ fontSize: 8, color: muted, letterSpacing: 0.8, textTransform: 'uppercase', fontWeight: 700 }}>{x.lbl}</div>
              <div style={{ fontSize: 14, color: x.color, fontWeight: 800, marginTop: 1, fontFamily: 'JetBrains Mono, monospace' }}>
                {x.val}
                {x.sub && <span style={{ fontSize: 9, color: muted, marginLeft: 4, fontWeight: 600 }}>{x.sub}</span>}
              </div>
            </div>
          ))}
        </div>

        {/* Pill tabs */}
        <div style={{
          marginTop: 10, display: 'flex', gap: 2, background: surface2,
          padding: 3, borderRadius: 999,
        }}>
          {tabs.map(t => {
            const active = tab === t.id;
            return (
              <button key={t.id} onClick={() => setTab(t.id)} style={{
                flex: 1, border: 'none', cursor: 'pointer',
                background: active ? '#fff' : 'transparent',
                color: active ? '#0A1220' : muted,
                fontFamily: 'Inter, sans-serif', fontWeight: 700, fontSize: 12,
                padding: '8px 0', borderRadius: 999,
                letterSpacing: 0.3, transition: 'all 120ms',
              }}>{t.label}</button>
            );
          })}
        </div>
      </div>

      {/* Main area — leaves room for the fixed footer below */}
      <div style={{ flex: 1, position: 'relative', overflow: 'hidden', marginBottom: 96 }}>
        {tab === 'map' && (
          <div style={{ position: 'relative', width: '100%', height: '100%' }}>
            <LeafletDarkMap speakingTeams={speakingTeams} />

            {/* Scene chip */}
            <div style={{
              position: 'absolute', top: 12, left: 12, right: 12,
              background: 'rgba(15,26,44,0.85)', backdropFilter: 'blur(10px)',
              border: `1px solid ${hairline}`, borderRadius: 10,
              padding: '8px 12px', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
            }}>
              <div>
                <div style={{ fontSize: 8, color: muted, fontWeight: 700, letterSpacing: 0.8 }}>
                  {lang === 'ja' ? '現場' : 'SCENE'}
                </div>
                <div style={{ fontSize: 12, color: text, fontWeight: 700 }}>{s.address}</div>
              </div>
              <div style={{
                padding: '4px 8px', borderRadius: 999,
                background: `${accent}22`, color: accent,
                fontSize: 10, fontWeight: 800, fontFamily: 'JetBrains Mono, monospace',
              }}>200m</div>
            </div>

            {/* Legend */}
            <div style={{
              position: 'absolute', bottom: 110, left: 12,
              background: 'rgba(15,26,44,0.85)', backdropFilter: 'blur(10px)',
              border: `1px solid ${hairline}`, borderRadius: 8,
              padding: '6px 10px', display: 'flex', flexDirection: 'column', gap: 3,
              fontSize: 10, color: text,
            }}>
              {[
                { c: '#2B7FFF', l: lang === 'ja' ? '小隊' : 'Squad' },
                { c: '#FFC64D', l: s.command },
                { c: '#22D3EE', l: lang === 'ja' ? 'ドローン' : 'Drone' },
                { c: accent, l: s.fire },
              ].map(item => (
                <div key={item.l} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
                  <span style={{ width: 8, height: 8, borderRadius: 2, background: item.c }} />
                  <span>{item.l}</span>
                </div>
              ))}
            </div>
          </div>
        )}

        {tab === 'video' && (
          <div style={{ height: '100%', padding: 10, background: '#060A12', overflow: 'auto' }}>
            <div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
              <div style={{ position: 'relative', borderRadius: 8, overflow: 'hidden', border: `1px solid ${hairline}` }}>
                <VideoPlaceholder label={s.squadCam(2)} tone={0.1} />
              </div>
              <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
                <div style={{ borderRadius: 8, overflow: 'hidden', border: `1px solid ${hairline}` }}>
                  <VideoPlaceholder label={s.squadCam(1)} tone={0.3} aspect="1/1" />
                </div>
                <div style={{ borderRadius: 8, overflow: 'hidden', border: `1px solid ${hairline}` }}>
                  <VideoPlaceholder label={s.droneCam} tone={0.5} aspect="1/1" />
                </div>
              </div>
              <div style={{
                background: surface, border: `1px solid ${hairline}`, borderRadius: 8,
                padding: 12, color: text,
              }}>
                <div style={{ fontSize: 9, color: muted, letterSpacing: 1, textTransform: 'uppercase', fontWeight: 700 }}>
                  {s.incidentSummary}
                </div>
                <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10, marginTop: 6 }}>
                  <div>
                    <div style={{ fontSize: 9, color: muted }}>{lang === 'ja' ? '種別' : 'Type'}</div>
                    <div style={{ fontSize: 13, fontWeight: 800, color: accent }}>{s.incidentType}</div>
                  </div>
                  <div>
                    <div style={{ fontSize: 9, color: muted }}>{s.elapsed}</div>
                    <div style={{ fontSize: 13, fontWeight: 800, color: accent2, fontFamily: 'JetBrains Mono, monospace' }}>{fmtElapsed(elapsed)}</div>
                  </div>
                  <div style={{ gridColumn: '1 / -1' }}>
                    <div style={{ fontSize: 9, color: muted }}>{lang === 'ja' ? '場所' : 'Location'}</div>
                    <div style={{ fontSize: 12, fontWeight: 700 }}>{s.address}</div>
                  </div>
                </div>
              </div>
            </div>
          </div>
        )}

        {tab === 'chat' && (
          <ChatScreenBLive lang={lang} accent={accent} />
        )}

        {/* 再ブロック時（バックグラウンド復帰等で play() が再び拒否された）の
            復帰用カード。初回ゲートと違い接続中なので画面全体は塞がず、
            ただし見逃さない大きさ＋タップで即解除できるようにする。 */}
        {audioStarted && audioUnlockNeeded && (
          <button onClick={startAudioGate} style={{
            position: 'absolute', top: 40, left: '50%', transform: 'translateX(-50%)',
            background: 'linear-gradient(135deg, #FF7A1C, #E6352A)', color: '#fff',
            padding: '12px 20px', borderRadius: 14, border: 'none', cursor: 'pointer',
            fontSize: 14, fontWeight: 900, letterSpacing: 0.3,
            display: 'flex', alignItems: 'center', gap: 10,
            boxShadow: '0 8px 24px rgba(230,53,42,0.55)',
            zIndex: 12, whiteSpace: 'nowrap',
            animation: 'pulseGate 1.4s ease-in-out infinite',
          }}>
            <IconSpeaker size={18} />
            {lang === 'ja' ? 'タップで音声を再開（聞こえていません）' : 'TAP TO RESUME AUDIO'}
          </button>
        )}

        {/* マイク拒否警告：発話不可の致命状態。タップで再許可を試行。 */}
        {audioStarted && micDenied && (
          <button
            onClick={() => { preacquireLiveMic().then(ok => setMicDenied(!ok)); }}
            style={{
              position: 'absolute', top: audioUnlockNeeded ? 96 : 40, left: '50%', transform: 'translateX(-50%)',
              background: 'rgba(239,68,68,0.97)', color: '#fff',
              padding: '10px 16px', borderRadius: 12, border: '1px solid #fff3', cursor: 'pointer',
              fontSize: 12, fontWeight: 800, letterSpacing: 0.2, lineHeight: 1.4,
              maxWidth: '90%', textAlign: 'center',
              boxShadow: '0 8px 24px rgba(239,68,68,0.5)', zIndex: 13,
            }}>
            ⚠ {lang === 'ja'
              ? 'マイクが許可されていません（発話不可）。タップして再許可'
              : 'Microphone blocked — cannot transmit. Tap to allow'}
          </button>
        )}

        {/* Receiving toast (overlay on top of content area) */}
        {speakerNames.length > 0 && (
          <div style={{
            position: 'absolute', top: 10, left: '50%',
            transform: 'translateX(-50%)',
            background: 'rgba(239,68,68,0.95)',
            color: '#fff', padding: '6px 14px', borderRadius: 999,
            fontSize: 11, fontWeight: 800, letterSpacing: 0.4,
            boxShadow: '0 6px 16px rgba(239,68,68,0.4)',
            display: 'flex', alignItems: 'center', gap: 8,
            zIndex: 7, animation: 'toastIn 160ms ease-out',
            maxWidth: '88%', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
          }}>
            <span style={{ width: 7, height: 7, borderRadius: 999, background: '#fff', animation: 'pulseDot 0.7s infinite' }} />
            {lang === 'ja' ? '受信中: ' : 'RX: '}{speakerNames.join(', ')}
          </div>
        )}
      </div>

      {/* Self-talk indicator */}
      {ptt.holding && (
        <div style={{
          position: 'absolute', top: '48%', left: '50%', transform: 'translate(-50%, -50%)',
          background: 'rgba(10,18,32,0.92)', color: '#fff',
          padding: '14px 22px', borderRadius: 16,
          border: `2px solid ${accent2}`,
          fontFamily: 'Inter, sans-serif', fontWeight: 800, fontSize: 13,
          boxShadow: `0 0 0 8px ${accent2}22, 0 12px 28px rgba(0,0,0,0.5)`,
          zIndex: 6, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 8, minWidth: 160,
        }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
            <span style={{ width: 8, height: 8, borderRadius: 999, background: '#EF4444', animation: 'pulseDot 0.8s infinite' }} />
            <span style={{ letterSpacing: 0.8 }}>{STRINGS[lang].talking}</span>
            <span style={{ fontFamily: 'JetBrains Mono, monospace', color: accent2 }}>{fmtElapsed(Math.floor(ptt.holdSec))}</span>
          </div>
          <Waveform samples={ptt.waveSamples} color={accent2} height={28} bars={24} />
        </div>
      )}

      {/* PTT error banner */}
      {ptt.pttError && (
        <div style={{
          position: 'absolute', top: 200, left: 12, right: 12, zIndex: 8,
          background: 'rgba(239,68,68,0.95)', color: '#fff',
          padding: '8px 12px', borderRadius: 8,
          fontSize: 11, fontWeight: 700,
        }}>
          ⚠ {ptt.pttError}
        </div>
      )}

      {/* Connection error banner */}
      {status === 'error' && (
        <div style={{
          position: 'absolute', top: 200, left: 12, right: 12, zIndex: 8,
          background: 'rgba(239,68,68,0.95)', color: '#fff',
          padding: '8px 12px', borderRadius: 8,
          fontSize: 11, fontWeight: 700, whiteSpace: 'pre-wrap',
        }}>
          ⚠ 接続失敗: {errorMsg}
        </div>
      )}

      {cameraActive && (
        <div style={{
          position: 'absolute', top: 220, left: 12, right: 12,
          background: `linear-gradient(135deg, ${accent2}, #0E7A9E)`,
          color: '#03192A', padding: '10px 14px', borderRadius: 10,
          display: 'flex', justifyContent: 'space-between', alignItems: 'center',
          fontWeight: 800, fontSize: 12, zIndex: 5,
          boxShadow: `0 8px 20px ${accent2}55`,
        }}>
          <span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
            <span style={{ width: 8, height: 8, borderRadius: 999, background: '#EF4444', animation: 'pulseDot 1s infinite' }} />
            {STRINGS[lang].youAreLive}
          </span>
          <button onClick={() => setCameraActive(false)} style={{
            border: '1px solid #03192A66', background: 'rgba(3,25,42,0.15)', color: '#03192A',
            padding: '4px 9px', fontSize: 10, fontWeight: 800, borderRadius: 6, cursor: 'pointer',
          }}>{STRINGS[lang].stopStreaming}</button>
        </div>
      )}

      {/* Bottom action bar — fixed to viewport */}
      <div style={{
        position: 'fixed', left: 0, right: 0, bottom: 0, zIndex: 9,
        padding: '12px 14px calc(20px + env(safe-area-inset-bottom))',
        background: `linear-gradient(180deg, transparent 0%, ${bg} 30%)`,
        display: 'flex', gap: 10, alignItems: 'center',
      }}>
        <button onClick={() => setSosStep('confirm')} style={{
          width: 64, height: 64, borderRadius: 16, border: 'none', cursor: 'pointer',
          background: `linear-gradient(135deg, #FF5A4E, #D6201A)`,
          color: '#fff', fontFamily: 'Inter, sans-serif', fontWeight: 900,
          display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
          boxShadow: 'inset 0 2px 0 rgba(255,255,255,0.2), 0 6px 14px rgba(214,32,26,0.45)',
          gap: 2,
        }}>
          <IconSOS size={18} />
          <span style={{ fontSize: 11, letterSpacing: 1 }}>SOS</span>
        </button>

        <button
          onMouseDown={(e) => { e.preventDefault(); ptt.begin(); }}
          onMouseUp={(e) => { e.preventDefault(); ptt.end(); }}
          onMouseLeave={() => ptt.end()}
          onTouchStart={(e) => { e.preventDefault(); ptt.begin(); }}
          onTouchEnd={(e) => { e.preventDefault(); ptt.end(); }}
          onTouchCancel={() => ptt.end()}
          disabled={status !== 'connected'}
          style={{
            flex: 1, height: 64, borderRadius: 16, border: 'none',
            cursor: status === 'connected' ? 'pointer' : 'not-allowed',
            background: ptt.holding
              ? `linear-gradient(135deg, #4AE08A, #15994F)`
              : status === 'connected'
                ? `linear-gradient(135deg, #2CC27A, #0F8A47)`
                : `linear-gradient(135deg, #3A5060, #1F2E3A)`,
            color: '#fff', fontFamily: 'Inter, sans-serif', fontWeight: 900,
            display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
            boxShadow: ptt.holding
              ? `inset 0 2px 0 rgba(255,255,255,0.25), 0 0 0 4px ${accent2}44, 0 8px 20px rgba(44,194,122,0.5)`
              : 'inset 0 2px 0 rgba(255,255,255,0.2), 0 6px 14px rgba(15,138,71,0.45)',
            transition: 'all 160ms', userSelect: 'none', touchAction: 'none',
            // 他者が PTT を押している間は半透明ヒント（publication 連動／音声 level ではない）
            // disabled にしない＝ claim API が最終判定
            opacity: status !== 'connected' ? 0.6
                   : (!ptt.holding && othersPublishingNames.length > 0) ? 0.55
                   : 1,
          }}>
          {ptt.holding ? (
            <>
              <div style={{ width: 60, height: 28 }}>
                <Waveform samples={ptt.waveSamples} color="#E6FFF2" height={28} bars={20} />
              </div>
              <span style={{ fontSize: 14, fontFamily: 'JetBrains Mono, monospace', color: '#E6FFF2' }}>
                {fmtElapsed(Math.floor(ptt.holdSec))}
              </span>
            </>
          ) : status !== 'connected' ? (
            <>
              <IconMic size={22} />
              <span style={{ fontSize: 15, letterSpacing: 1 }}>
                {lang === 'ja' ? '接続待機中' : 'WAITING'}
              </span>
            </>
          ) : othersPublishingNames.length > 0 ? (
            <>
              <span style={{
                width: 8, height: 8, borderRadius: 999, background: '#fff',
                animation: 'pulseDot 0.8s infinite',
              }} />
              <span style={{ fontSize: 13, letterSpacing: 0.5 }}>
                {lang === 'ja' ? '他者発話中 / 押して割込' : 'BUSY / HOLD TO BREAK IN'}
              </span>
            </>
          ) : (
            <>
              <IconMic size={22} />
              <span style={{ fontSize: 15, letterSpacing: 1 }}>
                {lang === 'ja' ? 'PTT 押して発話' : 'PTT HOLD TO TALK'}
              </span>
            </>
          )}
        </button>

        <button onClick={() => setCameraActive(v => !v)} style={{
          width: 64, height: 64, borderRadius: 16, border: 'none', cursor: 'pointer',
          background: cameraActive
            ? `linear-gradient(135deg, #22D3EE, #0891B2)`
            : `linear-gradient(135deg, #1F2E48, #0F1A2C)`,
          color: cameraActive ? '#03192A' : text,
          fontFamily: 'Inter, sans-serif', fontWeight: 800,
          display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
          boxShadow: cameraActive
            ? 'inset 0 2px 0 rgba(255,255,255,0.25), 0 6px 14px rgba(34,211,238,0.45)'
            : 'inset 0 1px 0 rgba(255,255,255,0.08), 0 4px 10px rgba(0,0,0,0.3)',
          border: `1px solid ${cameraActive ? 'transparent' : hairline}`,
          gap: 2,
        }}>
          <IconCam size={18} />
          <span style={{ fontSize: 9, fontWeight: 800 }}>{lang === 'ja' ? 'カメラ' : 'CAM'}</span>
        </button>
      </div>

      {!isFull && (
        <div style={{
          position: 'absolute', bottom: 6, left: '50%', transform: 'translateX(-50%)',
          width: 120, height: 4, background: '#3A424E', borderRadius: 999,
        }} />
      )}

      <SosModal lang={lang} step={sosStep} onCancel={() => setSosStep('idle')} onConfirm={sosConfirm} />

      {/* 音声有効化ゲート — 起動時に全画面を塞ぎ、タップするまで進めない。
          無線の音声を取りこぼさないための必須ステップ。 */}
      {!audioStarted && (
        <div
          onClick={startAudioGate}
          role="button"
          style={{
            position: 'absolute', inset: 0, zIndex: 100,
            background: 'rgba(6,10,18,0.97)', backdropFilter: 'blur(3px)',
            display: 'flex', flexDirection: 'column',
            alignItems: 'center', justifyContent: 'center',
            padding: 28, textAlign: 'center', cursor: 'pointer',
            fontFamily: 'Inter, sans-serif', animation: 'fadeIn 200ms ease-out',
          }}>
          <div style={{
            width: 84, height: 84, borderRadius: 999, marginBottom: 18,
            background: `${accent}1a`, border: `1.5px solid ${accent}55`,
            display: 'flex', alignItems: 'center', justifyContent: 'center',
            color: accent, boxShadow: `0 0 0 8px ${accent}0d`,
          }}>
            <IconSpeaker size={38} />
          </div>
          <div style={{ fontSize: 20, fontWeight: 900, color: text, marginBottom: 12 }}>
            {lang === 'ja' ? '音声を有効化してください' : 'Enable Audio'}
          </div>
          <div style={{ fontSize: 13, color: muted, lineHeight: 1.7, maxWidth: 290, marginBottom: 26 }}>
            {lang === 'ja'
              ? 'タップしないと無線の音声が一切聞こえません。下のボタンを押して受信を開始してください。続けてマイクの使用許可を求められたら「許可」してください。'
              : 'Radio audio will NOT play until you tap. Press the button below to start. When prompted, allow microphone access.'}
          </div>
          <button
            onClick={(e) => { e.stopPropagation(); startAudioGate(); }}
            style={{
              width: '100%', maxWidth: 300, padding: '18px 24px',
              borderRadius: 16, border: 'none', cursor: 'pointer',
              background: `linear-gradient(135deg, ${accent}, #E6352A)`,
              color: '#fff', fontSize: 17, fontWeight: 900, letterSpacing: 0.5,
              display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 10,
              animation: 'pulseGate 1.6s ease-in-out infinite',
            }}>
            <IconMic size={22} />
            {lang === 'ja' ? 'タップして音声を開始' : 'TAP TO START AUDIO'}
          </button>
          <div style={{ marginTop: 18, fontSize: 11, color: '#FF9F1C', fontWeight: 700 }}>
            {lang === 'ja' ? '⚠ タップするまで受信できません' : '⚠ Reception is OFF until you tap'}
          </div>
        </div>
      )}

      {window.NetLoggerHUD && netStats && <window.NetLoggerHUD stats={netStats} visible={debugNet} />}
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// ChatScreenBLive — dummy auto-feeding chat (same behavior as PhoneB's ChatScreenB)
// ─────────────────────────────────────────────────────────────
function ChatScreenBLive({ lang, accent }) {
  const s = STRINGS[lang];
  const [messages, setMessages] = React.useState(s.chatMessages);
  const selfId = lang === 'ja' ? '指揮隊' : 'Command';
  React.useEffect(() => { setMessages(s.chatMessages); }, [lang]);

  React.useEffect(() => {
    const newMsg = lang === 'ja'
      ? [
          { from: '第1小隊', text: '北側階段、煙多し。進入可能。' },
          { from: '指揮隊', text: '了解。換気開始を依頼。' },
        ]
      : [
          { from: 'Squad 1', text: 'North stairwell smoke. Entry ok.' },
          { from: 'Command', text: 'Copy. Begin ventilation.' },
        ];
    let idx = 0;
    const id = setInterval(() => {
      if (idx >= newMsg.length) return;
      const d = new Date();
      const t = `${pad(d.getHours())}:${pad(d.getMinutes())}`;
      setMessages(prev => [...prev, { t, ...newMsg[idx] }]);
      idx++;
    }, 7000);
    return () => clearInterval(id);
  }, [lang]);

  return (
    <div style={{
      height: '100%', background: '#0A1220',
      padding: '10px 12px', overflowY: 'auto',
      display: 'flex', flexDirection: 'column', gap: 8,
    }}>
      {messages.map((m, i) => {
        const self = m.from === selfId;
        return (
          <div key={i} style={{ alignSelf: self ? 'flex-end' : 'flex-start', maxWidth: '82%' }}>
            <div style={{
              fontSize: 9, color: '#7C8BA3', fontFamily: 'JetBrains Mono, monospace',
              marginBottom: 3, textAlign: self ? 'right' : 'left',
            }}>
              {m.from} · {m.t}
            </div>
            <div style={{
              background: self ? accent : '#14223A',
              color: self ? '#fff' : '#E6ECF2',
              padding: '8px 12px',
              borderRadius: self ? '14px 14px 4px 14px' : '14px 14px 14px 4px',
              fontSize: 12, lineHeight: 1.5, whiteSpace: 'pre-line',
              border: self ? 'none' : '1px solid #1C2A44',
              fontWeight: 500,
            }}>
              {m.text}
            </div>
          </div>
        );
      })}
    </div>
  );
}

window.PhoneBLive = PhoneBLive;
