<template>
  <view class="cloud-law-call">
    <!-- 仅在"出错 / 已结束"时显示过渡页；正常 connecting 期间直接渲染会议外壳，
         避免出现"正在进入视频咨询"这一中间画面 -->
    <view v-if="joinError || ended" class="entry-state">
      <view class="entry-avatar">{{ entryInitial }}</view>
      <text class="entry-title">{{ entryTitle }}</text>
      <text class="entry-desc">{{ joinError || stateText }}</text>
      <button v-if="joinError" class="retry-btn" @click="joinMeeting">重新进入</button>
      <button v-else-if="ended && buildMiniProgramReturnUrl()" class="retry-btn" @click="goToConsultDetail">查看咨询详情</button>
    </view>

    <view v-else class="call-shell" :class="{ 'voice-mode': isVoiceMode }">
      <view v-if="!isVoiceMode" class="remote-stage">
        <VideoTile
          v-if="primaryTile"
          :key="`main-${primaryTile.userId}-${primaryTile.videoTrack ? primaryTile.videoTrack.id : 'novideo'}`"
          class="main-video clean-video-tile"
          :user-id="primaryTile.userId"
          :name="primaryTile.name"
          :is-local="primaryTile.isLocal"
          :is-host="primaryTile.isHost"
          :audio-enabled="primaryTile.audioEnabled"
          :video-enabled="primaryTile.videoEnabled"
          :audio-track="primaryTile.audioTrack"
          :video-track="primaryTile.videoTrack"
          :is-speaking="!!speakingMap[primaryTile.userId]"
        />
        <view v-else class="empty-video">
          <view class="entry-avatar">{{ entryInitial }}</view>
          <text class="entry-title">在线律师</text>
          <text class="entry-desc">等待视频画面…</text>
        </view>
      </view>

      <view v-else class="voice-stage">
        <image class="voice-bg" :src="bgImage" mode="aspectFill"></image>
        <view class="voice-profile">
          <image v-if="voiceAvatar" class="voice-avatar" :src="voiceAvatar" mode="aspectFill"></image>
          <view v-else class="voice-avatar voice-avatar-fallback">{{ voiceInitial }}</view>
          <text class="voice-name">{{ voiceName }}</text>
        </view>
      </view>

      <view v-if="!isVoiceMode && secondaryTile" class="self-preview" @click="swapPrimaryTile">
        <VideoTile
          :key="`pip-${secondaryTile.userId}-${secondaryTile.videoTrack ? secondaryTile.videoTrack.id : 'novideo'}`"
          class="self-video clean-video-tile"
          :user-id="secondaryTile.userId"
          :name="secondaryTile.name"
          :is-local="secondaryTile.isLocal"
          :is-host="secondaryTile.isHost"
          :audio-enabled="secondaryTile.audioEnabled"
          :video-enabled="secondaryTile.videoEnabled"
          :audio-track="secondaryTile.audioTrack"
          :video-track="secondaryTile.videoTrack"
          :is-speaking="!!speakingMap[secondaryTile.userId]"
          :compact="true"
        />
      </view>

      <view v-if="!isVoiceMode" class="video-topbar">
        <text v-if="isLawRole" class="video-back" @click="minimizeToMiniProgram">‹</text>
        <view class="top-window-icon" @click="swapPrimaryTile"></view>
        <text class="top-duration">{{ durationText }}</text>
        <text class="top-plus">＋</text>
      </view>

      <view v-else class="voice-topbar">
        <text class="voice-back" @click="onTopBack">‹</text>
      </view>

      <view v-if="isVoiceMode" class="voice-duration">{{ durationText }}</view>

      <view class="controls" :class="isVoiceMode ? 'voice-controls' : 'video-controls'">
        <view class="controls-row">
          <view class="control-item" @click="onCamToggle" @tap="onCamToggle">
            <view class="control-btn dark-btn" :class="{ disabled: !videoEnabled, loading: videoLoading }">
              <image class="control-image" :src="cameraIcon" mode="aspectFit"></image>
            </view>
            <text class="control-label">摄像头{{ videoEnabled ? '已开' : '已关' }}</text>
          </view>

          <view class="control-item" @click="toggleSpeaker">
            <view class="control-btn dark-btn" :class="{ disabled: speakerMuted }">
              <image class="control-image" :src="speakerIcon" mode="aspectFit"></image>
            </view>
            <text class="control-label">扬声器{{ speakerMuted ? '已关' : '已开' }}</text>
          </view>

          <view class="control-item" @click="onMicToggle" @tap="onMicToggle">
            <view class="control-btn dark-btn" :class="{ disabled: !audioEnabled, loading: audioLoading }">
              <image class="control-image" :src="micIcon" mode="aspectFit"></image>
            </view>
            <text class="control-label">麦克风{{ audioEnabled ? '已开' : '已关' }}</text>
          </view>
        </view>

        <view class="controls-row controls-row-bottom">
          <!-- 律师端：底部左侧为共享屏幕（替代通用会议页「聊天」能力） -->
          <view v-if="isLawRole" class="control-item" @click="onScreenToggle" @tap="onScreenToggle">
            <view
              class="control-btn dark-btn text-icon-btn"
              :class="{ active: screenSharingByMe, loading: screenLoading, disabled: screenDisabled && !screenSharingByMe }"
            >
              <text class="text-icon">{{ screenSharingByMe ? '停' : '屏' }}</text>
            </view>
            <text class="control-label">{{ screenSharingByMe ? '停止共享' : '共享屏幕' }}</text>
          </view>
          <view v-else class="control-item" @click="takeSnapshot" @tap="takeSnapshot">
            <view class="control-btn dark-btn text-icon-btn">
              <image v-if="!captureIconError" class="control-image" :src="captureIcon" mode="aspectFit" @error="captureIconError = true"></image>
              <text v-else class="text-icon">截</text>
            </view>
            <text class="control-label">截屏</text>
          </view>

          <view class="control-item" @click="hangup" @tap="hangup">
            <view class="hangup-btn" :class="{ loading: leaving }">
              <image class="hangup-image" :src="hangupIcon" mode="aspectFit"></image>
            </view>
            <text class="control-label">挂断</text>
          </view>

          <view class="control-item" @click="flipCamera">
            <view class="control-btn dark-btn text-icon-btn">
              <image v-if="!flipCameraIconError" class="control-image" :src="flipCameraIcon" mode="aspectFit" @error="flipCameraIconError = true"></image>
              <text v-else class="text-icon">↻</text>
            </view>
            <text class="control-label">镜头翻转</text>
          </view>
        </view>
      </view>

    </view>

    <view v-if="snapshotPreviewUrl" class="snapshot-preview-mask" @click="closeSnapshotPreview">
      <view class="snapshot-preview-panel" @click.stop>
        <image class="snapshot-preview-image" :src="snapshotPreviewUrl" mode="aspectFit"></image>
        <text class="snapshot-preview-tip">手机端请长按图片保存，电脑端可点击下载图片</text>
        <view class="snapshot-preview-actions">
          <button class="snapshot-preview-btn" @click="downloadSnapshotPreview">下载图片</button>
          <button class="snapshot-preview-btn secondary" @click="closeSnapshotPreview">关闭</button>
        </view>
      </view>
    </view>
  </view>
</template>

<script setup>
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { onLoad, onUnload } from '@dcloudio/uni-app'
import { useMeetingStore } from '@/store/meeting'
import { useUserStore } from '@/store/user'
import {
  MEETING_LOCAL_STATE_CONNECTED,
  MEETING_LOCAL_STATE_CONNECTING,
  MEETING_LOCAL_STATE_JOINING,
  MEETING_LOCAL_STATE_RECONNECTING,
  MEETING_LOCAL_STATE_LEAVING,
  MEETING_LOCAL_STATE_ENDED,
  MEETING_ENDED_REASON_LABEL
} from '@/constants/meeting'
import VideoTile from '@/components/meeting/VideoTile.vue'
import { minimizeCloudLawMeeting } from '@/utils/cloudLawMiniProgram'
import {
  notifyCloudLawBackendEnd,
  notifyCloudLawParentCallEnded,
  isCloudLawPcIframe,
  notifyNativeMiniProgramHangup
} from '@/utils/cloudLawVoiceCall'
import bgImage from '@/static/bg.png'
import hangupIcon from '@/static/cacel.png'
import voiceOnIcon from '@/static/vocie_calling_1.png'
import voiceOffIcon from '@/static/vocie_stop.png'
import videoOnIcon from '@/static/vodie_calling_1.png'
import videoOffIcon from '@/static/vodie_stop.png'
import speakerOnIcon from '@/static/laba_open.png'
import speakerOffIcon from '@/static/laba_stop.png'

const meetingStore = useMeetingStore()
const userStore = useUserStore()

const rawCode = ref('')
const callId = ref('')
const consultId = ref('')
const miniProgramReturnUrl = ref('')
const cloudLawApiBase = ref('')
const cloudLawToken = ref('')
// 调用方角色：'law' = 律师端（接单端），其余/空 = 用户端（applet）。
// 律师为参会者（非主持人），结束会议走 /api/law/voiceCall/{callId}/end；用户端默认 /api/applet/voiceConnection。
const cloudLawRole = ref('')
const callMode = ref('video')
const joining = ref(false)
const joinError = ref('')
const audioLoading = ref(false)
const videoLoading = ref(false)
const screenLoading = ref(false)
const leaving = ref(false)
const startedAt = ref(0)
const nowTs = ref(Date.now())
const ended = ref(false)
const preferSelfMain = ref(false)
let timerHandle = null
let stateStopWatch = null
let videoRetryHandle = null
let commandPollHandle = null
let commandPolling = false
const speakerMuted = ref(false)
const cameraFacing = ref('user')
const captureIcon = '/static/capture.png'
const flipCameraIcon = '/static/camera.png'
const captureIconError = ref(false)
const flipCameraIconError = ref(false)
const snapshotPreviewUrl = ref('')
const snapshotBusy = ref(false)
/** 用户点击挂断（区别于返回上一页最小化会议） */
const userInitiatedHangup = ref(false)
/** 律师端从小程序 web-view 返回：仅本地离会，不结束云律通话、不通知小程序通话已结束 */
const minimizingToMiniProgram = ref(false)

const digitsOf = (value) => String(value || '').replace(/\D/g, '').slice(0, 9)
const sameUser = (a, b) => String(a || '') === String(b || '')

const formatCode = (value) => {
  const digits = digitsOf(value)
  if (digits.length !== 9) return String(value || '')
  return `${digits.slice(0, 3)}-${digits.slice(3, 6)}-${digits.slice(6)}`
}

const formattedCode = computed(() => formatCode(rawCode.value))
const myUserId = computed(() => userStore.userInfo?.id || userStore.userInfo?.user_id || '')
const hostId = computed(() => meetingStore.currentRoom?.host_id || '')
const audioEnabled = computed(() => !!meetingStore.localAudioEnabled)
const videoEnabled = computed(() => !!meetingStore.localVideoEnabled)
const speakingMap = computed(() => meetingStore.speakingMap || {})
const isVoiceMode = computed(() => callMode.value === 'voice')
const isLawRole = computed(() => String(cloudLawRole.value).toLowerCase() === 'law')
// 结束会议接口基址：律师端 /api/law/voiceCall，用户端 /api/applet/voiceConnection
const voiceEndBase = computed(() => isLawRole.value ? '/api/law/voiceCall' : '/api/applet/voiceConnection')
const cameraIcon = computed(() => videoEnabled.value ? videoOnIcon : videoOffIcon)
const micIcon = computed(() => audioEnabled.value ? voiceOnIcon : voiceOffIcon)
const speakerIcon = computed(() => speakerMuted.value ? speakerOffIcon : speakerOnIcon)

const entryInitial = computed(() => {
  const name = userStore.userInfo?.nickname || userStore.userInfo?.username || '在'
  return String(name || '在').slice(0, 1)
})

const entryTitle = computed(() => {
  if (joinError.value) return '视频咨询接入失败'
  if (ended.value) return '视频咨询已结束'
  return '正在进入视频咨询'
})

const participants = computed(() => {
  const list = meetingStore.activeParticipants || []
  return list.map(p => {
    if (!sameUser(p.user_id, myUserId.value)) return p
    return {
      ...p,
      audio_enabled: meetingStore.localAudioEnabled,
      video_enabled: meetingStore.localVideoEnabled
    }
  })
})

const localTrackVersion = computed(() => {
  const producers = meetingStore.localProducers || {}
  return `${producers.audio || ''}-${producers.video || ''}-${producers.screen || ''}`
})

const screenSharingActive = computed(() => !!meetingStore.screenShare?.ownerUserId)
const screenSharingByMe = computed(() => {
  const owner = meetingStore.screenShare?.ownerUserId
  return !!owner && sameUser(owner, myUserId.value)
})
const screenSupported = computed(() => {
  // #ifdef H5
  return typeof navigator !== 'undefined' &&
    !!navigator.mediaDevices &&
    typeof navigator.mediaDevices.getDisplayMedia === 'function'
  // #endif
  // #ifndef H5
  return false
  // #endif
})
const screenDisabled = computed(() => {
  if (!screenSupported.value) return true
  if (screenSharingActive.value && !screenSharingByMe.value) return true
  return false
})
const screenDisabledReason = computed(() => {
  if (!screenSupported.value) return '当前环境不支持屏幕共享'
  if (screenSharingActive.value && !screenSharingByMe.value) return '已有成员正在共享屏幕'
  return ''
})

const screenTile = computed(() => {
  const owner = meetingStore.screenShare?.ownerUserId
  if (!owner) return null
  localTrackVersion.value
  remoteTrackVersion.value
  const isLocal = sameUser(owner, myUserId.value)
  const track = isLocal
    ? meetingStore.getLocalTrack('screen')
    : meetingStore.getRemoteTrack(owner, 'screen')
  if (!track) return null
  return {
    key: `screen_${owner}`,
    userId: owner,
    name: isLocal ? '你' : nameOf(owner),
    isLocal,
    isHost: sameUser(owner, hostId.value),
    audioEnabled: false,
    videoEnabled: true,
    audioTrack: null,
    videoTrack: track
  }
})

const remoteTrackVersion = computed(() => {
  const parts = []
  const map = meetingStore.remoteConsumers || {}
  Object.keys(map).forEach(uid => {
    const slot = map[uid]
    parts.push(`${uid}:${slot?.audio?.id || ''}:${slot?.video?.id || ''}:${slot?.screen?.id || ''}`)
  })
  return parts.join('|')
})

const displayNameMap = computed(() => {
  const map = {}
  participants.value.forEach(p => {
    if (p.user_name) map[p.user_id] = p.user_name
  })
  const me = userStore.userInfo || {}
  const uid = me.id || me.user_id
  if (uid) map[uid] = me.nickname || me.username || map[uid]
  return map
})

const nameOf = (uid) => {
  if (sameUser(uid, myUserId.value)) return '你'
  return displayNameMap.value[uid] || `用户 ${uid}`
}

const tiles = computed(() => {
  localTrackVersion.value
  remoteTrackVersion.value

  const list = []
  participants.value.forEach(p => {
    if (!p.is_active && p.is_active !== undefined) return
    const isLocal = sameUser(p.user_id, myUserId.value)
    const audioTrack = isLocal ? meetingStore.getLocalTrack('audio') : meetingStore.getRemoteTrack(p.user_id, 'audio')
    const videoTrack = isLocal ? meetingStore.getLocalTrack('video') : meetingStore.getRemoteTrack(p.user_id, 'video')
    list.push({
      key: `u_${p.user_id}`,
      userId: p.user_id,
      name: nameOf(p.user_id),
      isLocal,
      isHost: sameUser(p.user_id, hostId.value),
      audioEnabled: isLocal ? audioEnabled.value : !!audioTrack,
      videoEnabled: isLocal ? videoEnabled.value : !!videoTrack,
      audioTrack,
      videoTrack
    })
  })

  if (myUserId.value && !list.some(t => t.isLocal)) {
    list.unshift({
      key: `u_${myUserId.value}`,
      userId: myUserId.value,
      name: '你',
      isLocal: true,
      isHost: sameUser(myUserId.value, hostId.value),
      audioEnabled: audioEnabled.value,
      videoEnabled: videoEnabled.value,
      audioTrack: meetingStore.getLocalTrack('audio'),
      videoTrack: meetingStore.getLocalTrack('video')
    })
  }
  return list
})

const selfTile = computed(() => tiles.value.find(t => t.isLocal) || null)
const remoteTile = computed(() => tiles.value.find(t => !t.isLocal && t.videoTrack) || tiles.value.find(t => !t.isLocal) || null)
const primaryTile = computed(() => {
  if (screenTile.value) return screenTile.value
  if (preferSelfMain.value && selfTile.value) return selfTile.value
  return remoteTile.value || selfTile.value
})
const secondaryTile = computed(() => {
  if (!primaryTile.value) return null
  if (primaryTile.value.isLocal) return remoteTile.value
  return selfTile.value
})
const voicePeer = computed(() => participants.value.find(p => !sameUser(p.user_id, myUserId.value)) || null)
const voiceName = computed(() => {
  if (voicePeer.value?.user_name) return voicePeer.value.user_name
  if (primaryTile.value && !primaryTile.value.isLocal) return primaryTile.value.name
  return '在线律师'
})
const voiceAvatar = computed(() => voicePeer.value?.user_avatar || '')
const voiceInitial = computed(() => String(voiceName.value || '在').slice(0, 1))

const isReady = computed(() => meetingStore.localState === MEETING_LOCAL_STATE_CONNECTED && !joinError.value && !ended.value)

const stateClass = computed(() => {
  switch (meetingStore.localState) {
    case MEETING_LOCAL_STATE_CONNECTED: return 'connected'
    case MEETING_LOCAL_STATE_RECONNECTING: return 'reconnecting'
    case MEETING_LOCAL_STATE_CONNECTING:
    case MEETING_LOCAL_STATE_JOINING: return 'connecting'
    case MEETING_LOCAL_STATE_LEAVING: return 'leaving'
    case MEETING_LOCAL_STATE_ENDED: return 'ended'
    default: return 'idle'
  }
})

const stateText = computed(() => {
  if (joinError.value) return joinError.value
  if (joining.value) return '正在接入视频画面…'
  switch (meetingStore.localState) {
    case MEETING_LOCAL_STATE_CONNECTED: return '已接通'
    case MEETING_LOCAL_STATE_RECONNECTING: return '重连中'
    case MEETING_LOCAL_STATE_CONNECTING:
    case MEETING_LOCAL_STATE_JOINING: return '连接中'
    case MEETING_LOCAL_STATE_LEAVING: return '正在挂断'
    case MEETING_LOCAL_STATE_ENDED: return '已结束'
    default: return '等待接通'
  }
})

const durationText = computed(() => {
  if (!startedAt.value) return '00:00'
  const diff = Math.max(0, Math.floor((nowTs.value - startedAt.value) / 1000))
  const h = Math.floor(diff / 3600)
  const m = Math.floor((diff % 3600) / 60)
  const s = diff % 60
  const pad = n => (n < 10 ? `0${n}` : String(n))
  return h > 0 ? `${pad(h)}:${pad(m)}:${pad(s)}` : `${pad(m)}:${pad(s)}`
})

const startTimer = () => {
  if (!startedAt.value) {
    const startIso = meetingStore.currentRoom?.started_at || meetingStore.currentRoom?.created_at
    startedAt.value = startIso ? Date.parse(startIso) : Date.now()
  }
  if (!timerHandle) {
    timerHandle = setInterval(() => { nowTs.value = Date.now() }, 1000)
  }
}

const localMediaError = ref('')

const resolveCloudLawApiUrl = (path) => {
  const base = String(cloudLawApiBase.value || '').replace(/\/+$/, '')
  return `${base}${path}`
}

const requestCloudLawCommands = (url) => {
  return new Promise((resolve, reject) => {
    uni.request({
      url,
      method: 'GET',
      timeout: 8000,
      success: (res) => {
        if (res.statusCode >= 200 && res.statusCode < 300) {
          resolve(res.data || {})
        } else {
          reject(res.data || { message: `HTTP ${res.statusCode}` })
        }
      },
      fail: reject
    })
  })
}

const notifyCloudLawEnd = () => {
  const role = isLawRole.value ? 'law' : 'caller'
  const roomCode = meetingStore.currentRoom?.room_code || formattedCode.value || ''
  console.log('[CloudLawVideoCall] notifyCloudLawEnd called', {
    callId: callId.value,
    role,
    roomCode,
    cloudLawApiBase: cloudLawApiBase.value,
    hasToken: !!cloudLawToken.value
  })
  if (!callId.value) {
    console.warn('[CloudLawVideoCall] notifyCloudLawEnd SKIP: callId 为空，不会发结束请求')
    return Promise.resolve()
  }
  return notifyCloudLawBackendEnd(callId.value, role, cloudLawApiBase.value, cloudLawToken.value, roomCode)
}

const buildMiniProgramReturnUrl = () => {
  const decodeRepeated = (value) => {
    let text = String(value || '').trim()
    if (!text) return ''
    for (let i = 0; i < 3; i++) {
      try {
        const next = decodeURIComponent(text)
        if (next === text) break
        text = next
      } catch (e) {
        break
      }
    }
    return text
  }
  const configured = decodeRepeated(miniProgramReturnUrl.value)
  if (configured) {
    return configured
  }
  if (!consultId.value) {
    // #ifdef H5
    try {
      const cachedConsultId = window.sessionStorage.getItem('echo_cloud_law_consult_id') || ''
      if (cachedConsultId) {
        return `/pages_work/ai_consult/ai_consult?id=${encodeURIComponent(cachedConsultId)}&mode=detail&from=videoCall`
      }
      const cached = window.sessionStorage.getItem('echo_cloud_law_return_url') || ''
      if (cached) return decodeRepeated(cached)
    } catch (e) {}
    // #endif
    return ''
  }
  return `/pages_work/ai_consult/ai_consult?id=${encodeURIComponent(consultId.value)}&mode=detail&from=videoCall`
}

let endedRedirectTimer = null
let miniProgramRedirected = false
let miniProgramEndNotified = false
let miniProgramRedirectRetryTimer = null

const getMiniProgram = () => {
  return typeof window !== 'undefined' && window.wx && window.wx.miniProgram ? window.wx.miniProgram : null
}

const navigateMiniProgramToReturnUrl = (returnUrl) => {
  const mp = getMiniProgram()
  if (!mp) return false
  const url = String(returnUrl || '').trim()
  const tryBack = () => {
    try {
      if (typeof mp.navigateBack === 'function') {
        mp.navigateBack({ delta: 1 })
      }
    } catch (e) {
      console.warn('[CloudLawVideoCall] miniProgram navigateBack failed', e)
    }
  }
  if (!url) {
    tryBack()
    return true
  }
  // 用 fail 回调级联：reLaunch（最可靠，可跨分包，会清栈）→ redirectTo → navigateTo → navigateBack。
  // 关键：上一版直接 mp.reLaunch({url}) 不带回调、调用即 return true，jweixin 静默失败时
  //   仍被判成功、阻断后续兜底，表现为「停在 EchoChat 已结束页、不跳咨询详情」。
  const tryNavigate = () => {
    if (typeof mp.navigateTo === 'function') {
      mp.navigateTo({ url, fail: () => tryBack() })
    } else {
      tryBack()
    }
  }
  const tryRedirect = () => {
    if (typeof mp.redirectTo === 'function') {
      mp.redirectTo({ url, fail: () => tryNavigate() })
    } else {
      tryNavigate()
    }
  }
  if (typeof mp.reLaunch === 'function') {
    mp.reLaunch({ url, fail: () => tryRedirect() })
  } else {
    tryRedirect()
  }
  return true
}

const notifyMiniProgramCallEnded = () => {
  if (isLawRole.value) return
  const returnUrl = buildMiniProgramReturnUrl()
  const payload = {
    type: 'cloudLawCallEnded',
    action: 'cloudLawCallEnded',
    callId: callId.value,
    consultId: consultId.value,
    returnUrl
  }
  const miniProgram = getMiniProgram()
  try {
    // postMessage 只在 web-view 销毁/分享等时机投递；先发一份，作为小程序侧 onMessage 的兜底
    if (miniProgram && typeof miniProgram.postMessage === 'function') {
      miniProgram.postMessage({ data: payload })
    }
  } catch (e) {
    console.warn('[CloudLawVideoCall] postMessage failed', e)
  }
  if (miniProgramRedirected) return
  // 首选：直接用 wx.miniProgram 导航到咨询详情 returnUrl（reLaunch 会清栈、销毁承载页，
  //   触发承载页 onUnload 调同域 /end 收尾）。这是 hash 路由 SPA 内唯一能直达详情页的路径
  //   ——onWebViewLoad/@message 在 SPA 内 hash 变化时不触发，URL 桥接根本不会被原生页命中。
  if (navigateMiniProgramToReturnUrl(returnUrl)) {
    miniProgramRedirected = true
    return
  }
  // 兜底：URL 桥接（改地址加 cloudLawHangup=1，触发原生页 @load 收尾+跳详情）。仅在无 wx.miniProgram 时才会走到。
  if (notifyNativeMiniProgramHangup()) {
    miniProgramRedirected = true
    return
  }
  if (miniProgramRedirectRetryTimer) return
  let retry = 0
  miniProgramRedirectRetryTimer = setInterval(() => {
    retry += 1
    if (navigateMiniProgramToReturnUrl(returnUrl) || retry >= 10) {
      clearInterval(miniProgramRedirectRetryTimer)
      miniProgramRedirectRetryTimer = null
      if (retry < 10) miniProgramRedirected = true
    }
  }, 300)
}

const notifyMiniProgramCallEndedOnce = () => {
  if (miniProgramEndNotified) return
  miniProgramEndNotified = true
  notifyMiniProgramCallEnded()
}

const scheduleEndedRedirect = () => {
  if (endedRedirectTimer || isLawRole.value) return
  endedRedirectTimer = setTimeout(() => {
    endedRedirectTimer = null
    notifyCloudLawEnd().finally(() => notifyMiniProgramCallEndedOnce())
  }, 1200)
}

const goToConsultDetail = () => {
  // 结束页按钮必须强制重试，不能走 notifyMiniProgramCallEndedOnce()。
  // 前面自动跳转可能已经把 miniProgramEndNotified / miniProgramRedirected 置为 true，
  // 但实际 wx.miniProgram 导航可能静默失败，导致用户点「查看咨询详情」也被 once guard 拦掉。
  miniProgramEndNotified = false
  miniProgramRedirected = false
  console.log('[CloudLawVideoCall] 查看咨询详情 CLICK force redirect', {
    callId: callId.value,
    consultId: consultId.value,
    returnUrl: buildMiniProgramReturnUrl()
  })
  notifyCloudLawEnd().finally(() => notifyMiniProgramCallEnded())
}

const pollCommandsOnce = async () => {
  if (!callId.value || !meetingStore.isInMeeting || leaving.value || ended.value || commandPolling) return
  commandPolling = true
  try {
    const room = meetingStore.currentRoom?.room_code || formattedCode.value || ''
    const path = `/api/applet/voiceConnection/publicCommands/${encodeURIComponent(callId.value)}${room ? `?roomCode=${encodeURIComponent(room)}` : ''}`
    const resp = await requestCloudLawCommands(resolveCloudLawApiUrl(path))
    const payload = resp?.data || resp || {}
    const commands = payload.commands || payload.data?.commands || []
    for (const command of commands) {
      await executeCloudLawCommand(command)
    }
  } catch (err) {
    console.warn('[CloudLawVideoCall] poll commands failed', err)
  } finally {
    commandPolling = false
  }
}

const startCommandPolling = () => {
  // 律师端（接单端）无 publicCommands 远程指令通道，跳过轮询避免无谓 404
  if (isLawRole.value) return
  if (!callId.value || commandPollHandle) return
  pollCommandsOnce()
  commandPollHandle = setInterval(pollCommandsOnce, 1200)
}

const stopCommandPolling = () => {
  if (commandPollHandle) {
    clearInterval(commandPollHandle)
    commandPollHandle = null
  }
}

const setSpeakerMuted = (muted) => {
  speakerMuted.value = muted
  // #ifdef H5
  try {
    const audioEls = document.querySelectorAll('.cloud-law-call audio')
    audioEls.forEach(el => {
      el.muted = muted
      el.volume = muted ? 0 : 1
    })
  } catch (err) {
    console.warn('[CloudLawVideoCall] set speaker failed', err)
  }
  // #endif
}

const toggleSpeaker = () => {
  setSpeakerMuted(!speakerMuted.value)
}

const isMobileSnapshotBrowser = () => {
  // #ifdef H5
  const ua = navigator?.userAgent || ''
  return /Android|iPhone|iPad|iPod|Mobile|MicroMessenger/i.test(ua)
  // #endif
  // #ifndef H5
  return false
  // #endif
}

const findSnapshotVideo = () => {
  // #ifdef H5
  const selectors = [
    '.cloud-law-call .main-video video',
    '.cloud-law-call .remote-stage video',
    '.cloud-law-call video'
  ]
  for (const selector of selectors) {
    const list = Array.from(document.querySelectorAll(selector))
    const video = list.find(el => el && el.videoWidth && el.videoHeight && el.readyState >= 2)
    if (video) return video
  }
  // #endif
  return null
}

const downloadSnapshotDataUrl = (dataUrl) => {
  // #ifdef H5
  const a = document.createElement('a')
  a.href = dataUrl
  a.download = `cloud-law-video-${Date.now()}.png`
  document.body.appendChild(a)
  a.click()
  document.body.removeChild(a)
  // #endif
}

const closeSnapshotPreview = () => {
  snapshotPreviewUrl.value = ''
}

const downloadSnapshotPreview = () => {
  if (!snapshotPreviewUrl.value) return
  downloadSnapshotDataUrl(snapshotPreviewUrl.value)
  uni.showToast({ title: isMobileSnapshotBrowser() ? '如未下载，请长按图片保存' : '截屏已下载', icon: 'none' })
}

const takeSnapshot = () => {
  if (snapshotBusy.value) return
  snapshotBusy.value = true
  setTimeout(() => {
    snapshotBusy.value = false
  }, 600)
  // #ifdef H5
  try {
    const video = findSnapshotVideo()
    if (!video || !video.videoWidth || !video.videoHeight) {
      uni.showToast({ title: '当前无视频画面可截屏', icon: 'none' })
      return
    }
    const canvas = document.createElement('canvas')
    canvas.width = video.videoWidth
    canvas.height = video.videoHeight
    const ctx = canvas.getContext('2d')
    ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
    const dataUrl = canvas.toDataURL('image/png')
    snapshotPreviewUrl.value = dataUrl
    if (isMobileSnapshotBrowser()) {
      uni.showToast({ title: '截屏成功，长按图片保存', icon: 'none' })
    } else {
      downloadSnapshotDataUrl(dataUrl)
      uni.showToast({ title: '截屏已下载', icon: 'success' })
    }
  } catch (err) {
    console.warn('[CloudLawVideoCall] snapshot failed', err)
    uni.showToast({ title: '截屏失败', icon: 'none' })
  }
  // #endif
  // #ifndef H5
  uni.showToast({ title: '当前环境暂不支持截屏', icon: 'none' })
  // #endif
}

const flipCameraByCommand = async (value) => {
  const nextFacing = value === 'front' || value === 'user'
    ? 'user'
    : value === 'back' || value === 'environment'
      ? 'environment'
      : (cameraFacing.value === 'user' ? 'environment' : 'user')
  cameraFacing.value = nextFacing
  try {
    if (meetingStore.localProducers?.video) {
      await meetingStore.switchLocalCameraFacing(nextFacing)
    } else {
      const stream = await navigator.mediaDevices.getUserMedia({
        audio: false,
        video: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: nextFacing }
      })
      const track = stream.getVideoTracks()[0]
      await meetingStore.startLocalVideo(undefined, track)
    }
    localMediaError.value = ''
  } catch (err) {
    console.warn('[CloudLawVideoCall] flip camera failed', err)
    localMediaError.value = `镜头翻转：${err?.message || err}`
  }
}

const flipCamera = () => {
  flipCameraByCommand()
}

const onScreenToggle = async () => {
  if (screenLoading.value) return
  if (screenDisabled.value && !screenSharingByMe.value) {
    uni.showToast({ title: screenDisabledReason.value || '无法共享屏幕', icon: 'none' })
    return
  }
  screenLoading.value = true
  try {
    if (screenSharingByMe.value) {
      await meetingStore.stopScreenShare()
    } else {
      await meetingStore.startScreenShare()
    }
  } catch (err) {
    if (err?.name !== 'NotAllowedError') {
      uni.showToast({ title: err?.message || '屏幕共享失败', icon: 'none' })
    }
  } finally {
    screenLoading.value = false
  }
}

const executeCloudLawCommand = async (cmd = {}) => {
  const command = String(cmd.command || cmd.type || '').toLowerCase()
  const value = cmd.value
  if (command === 'speaker') {
    setSpeakerMuted(value === 'off' || value === false || value === 'false' || value === '0')
    return
  }
  if (command === 'screenshot') {
    takeSnapshot()
    return
  }
  if (command === 'flipcamera' || command === 'flip_camera') {
    await flipCameraByCommand(value)
  }
}


const joinMeeting = async () => {
  if (joining.value) return
  const code = formattedCode.value
  if (digitsOf(code).length !== 9) {
    joinError.value = '会议号为空'
    return
  }
  joining.value = true
  joinError.value = ''
  ended.value = false
  try {
    const currentCode = formatCode(meetingStore.currentRoom?.room_code || '')
    if (meetingStore.isInMeeting && currentCode && digitsOf(currentCode) === digitsOf(code)) {
      startTimer()
      return
    }
    if (meetingStore.isInMeeting) {
      await meetingStore.leave().catch(() => {})
    }
    const startWithVideo = !isVoiceMode.value
    meetingStore.devicePreview = {
      audioDeviceId: '',
      videoDeviceId: '',
      speakerDeviceId: '',
      displayName: userStore.userInfo?.nickname || userStore.userInfo?.username || '',
      startAudio: true,
      startVideo: startWithVideo
    }

    // 关键：和 preview.vue 一致，进会前先 getUserMedia 拿到 audio+video 的原始 track，
    // 再把 track 直接交给 joinAndEnter 复用，避免 mediasoup 入会后再次 getUserMedia
    // 在 iOS Safari/微信 WebView 上触发 NotReadableError / 权限拒绝。
    const prefs = await prepareLocalMediaTracks(startWithVideo)

    await meetingStore.joinAndEnter(code, '', prefs)
    startTimer()
    scheduleVideoRetry()
    startCommandPolling()
  } catch (err) {
    joinError.value = err?.message || '视频咨询接入失败'
    console.error('[CloudLawVideoCall] join failed', err)
  } finally {
    joining.value = false
  }
}

/**
 * 进会前同步申请本地音视频。与 preview.vue 同策略：
 * 一次 getUserMedia 拿到 audio + video track 直接交给 mediasoup produce，
 * 避免入会后再调 getUserMedia 在某些 WebView/Safari 里失败导致黑屏。
 */
const prepareLocalMediaTracks = async (startWithVideo) => {
  const prefs = {
    startAudio: true,
    startVideo: startWithVideo,
    audioDeviceId: '',
    videoDeviceId: '',
    audioTrack: null,
    videoTrack: null
  }
  // #ifdef H5
  if (typeof navigator === 'undefined' || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
    console.warn('[CloudLawVideoCall] navigator.mediaDevices 不可用，跳过预申请')
    return prefs
  }
  try {
    const constraints = {
      audio: true,
      video: startWithVideo
        ? { width: { ideal: 640 }, height: { ideal: 480 }, frameRate: { ideal: 15, max: 24 }, facingMode: 'user' }
        : false
    }
    const stream = await navigator.mediaDevices.getUserMedia(constraints)
    prefs.audioTrack = stream.getAudioTracks()[0] || null
    prefs.videoTrack = stream.getVideoTracks()[0] || null
    // 如果用户拒了视频，stream 里不会有 video track，自动降级为不开摄像头
    if (startWithVideo && !prefs.videoTrack) {
      prefs.startVideo = false
    }
    if (!prefs.audioTrack) {
      prefs.startAudio = false
    }
  } catch (err) {
    console.error('[CloudLawVideoCall] prepare media failed', err)
    localMediaError.value = `预申请音视频失败：${err?.name || ''} ${err?.message || err}`
    // 拿不到也继续走 joinAndEnter，让后端协议建好；用户可以在入会后手动点麦/摄像头按钮重试
    prefs.startAudio = false
    prefs.startVideo = false
  }
  // #endif
  return prefs
}

const scheduleVideoRetry = () => {
  if (isVoiceMode.value || videoRetryHandle || videoEnabled.value) return
  videoRetryHandle = setTimeout(async () => {
    videoRetryHandle = null
    if (isVoiceMode.value || videoEnabled.value || leaving.value || !meetingStore.isInMeeting) return
    try {
      await meetingStore.startLocalVideo()
    } catch (err) {
      console.error('[CloudLawVideoCall] retry start video failed', err)
      localMediaError.value = `自动开摄像头失败：${err?.message || err}`
    }
    if (!meetingStore.localAudioEnabled) {
      try {
        await meetingStore.startLocalAudio()
      } catch (err) {
        console.error('[CloudLawVideoCall] retry start audio failed', err)
        localMediaError.value = `${localMediaError.value || ''} | 自动开麦克风失败：${err?.message || err}`
      }
    }
  }, 800)
}

const swapPrimaryTile = () => {
  if (!selfTile.value || !remoteTile.value) return
  preferSelfMain.value = !preferSelfMain.value
}

// 关键：在用户点击的"手势作用域"内自己 getUserMedia，把 track 传给 meetingStore，
// 避免 mediasoup 内部二次 getUserMedia 被 WebView 拒（NotAllowedError Permission denied）。
const acquireAudioTrack = async () => {
  if (!navigator?.mediaDevices?.getUserMedia) throw new Error('当前环境不支持 getUserMedia')
  const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false })
  return stream.getAudioTracks()[0]
}
const acquireVideoTrack = async (facingMode = 'user') => {
  if (!navigator?.mediaDevices?.getUserMedia) throw new Error('当前环境不支持 getUserMedia')
  const stream = await navigator.mediaDevices.getUserMedia({
    audio: false,
    video: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode }
  })
  return stream.getVideoTracks()[0]
}

const onMicToggle = async () => {
  if (audioLoading.value || leaving.value) return
  audioLoading.value = true
  try {
    if (meetingStore.localProducers?.audio) {
      // 已经有 producer：只 mute/unmute，不释放硬件（WebView 释放后再申请会被拒）
      await meetingStore.setLocalAudioMuted(audioEnabled.value)
    } else {
      // 没 producer：兜底走完整流程，在用户手势作用域内 getUserMedia
      const track = await acquireAudioTrack()
      await meetingStore.startLocalAudio(undefined, track)
    }
    localMediaError.value = ''
  } catch (err) {
    console.error('[CloudLawVideoCall] mic toggle failed', err)
    localMediaError.value = `麦克风：${err?.message || err}`
  } finally {
    audioLoading.value = false
  }
}

const onCamToggle = async () => {
  if (videoLoading.value || leaving.value) return
  videoLoading.value = true
  try {
    if (meetingStore.localProducers?.video) {
      await meetingStore.setLocalVideoMuted(videoEnabled.value)
    } else {
      const track = await acquireVideoTrack(cameraFacing.value)
      await meetingStore.startLocalVideo(undefined, track)
    }
    localMediaError.value = ''
  } catch (err) {
    console.error('[CloudLawVideoCall] cam toggle failed', err)
    localMediaError.value = `摄像头：${err?.message || err}`
  } finally {
    videoLoading.value = false
  }
}

const onTopBack = () => {
  if (isLawRole.value) {
    minimizeToMiniProgram()
  } else {
    hangup()
  }
}

/** 律师端返回小程序上一页：保留进行中通话，便于「返回会议」浮窗再次进入 */
const minimizeToMiniProgram = () => {
  if (leaving.value || ended.value || minimizingToMiniProgram.value) return
  minimizingToMiniProgram.value = true
  stopCommandPolling()
  meetingStore.leave().catch(() => {})
  if (minimizeCloudLawMeeting(callId.value)) {
    return
  }
  minimizingToMiniProgram.value = false
  uni.showToast({ title: '请使用左上角返回', icon: 'none' })
}

const hangup = async () => {
  console.log('[CloudLawVideoCall] hangup CLICKED', {
    leaving: leaving.value,
    isLawRole: isLawRole.value,
    callId: callId.value,
    roomCode: meetingStore.currentRoom?.room_code || formattedCode.value || ''
  })
  if (leaving.value) {
    console.log('[CloudLawVideoCall] hangup SKIP: leaving 已为 true（重复点击）')
    return
  }
  userInitiatedHangup.value = true
  leaving.value = true
  stopCommandPolling()
  try {
    console.log('[CloudLawVideoCall] hangup -> 调 notifyCloudLawEnd 前')
    // 先通知云律后端结束本次通话：
    // - 用户端：/api/applet/voiceConnection/{callId}/end
    // - 律师端：/api/law/voiceCall/{callId}/end → 后端 CloudLawEndMeeting 结束会议（双方都收到 room.ended）
    await notifyCloudLawEnd()
    console.log('[CloudLawVideoCall] hangup -> notifyCloudLawEnd 已返回')
    if (isLawRole.value) {
      // 律师为参会者（非主持人），EndRoom 是 host-only。会议已由上面的 /end 在后端结束，
      // 这里仅本地离会即可（直接 endMeeting 会因非主持人 403，徒增一次失败请求）。
      console.log('[CloudLawVideoCall] hangup(law) -> backend end + local leave', {
        roomCode: meetingStore.currentRoom?.room_code,
        callId: callId.value
      })
      await meetingStore.leave().catch(() => {})
    } else {
      // 云律视频咨询：发起人（小程序端用户）在 CreateRoomForInternal 时被加成 host，
      // 用户挂机 = 咨询结束，无条件 endMeeting 让律师端收到 room.ended 自动退出。
      // 不依赖 meetingStore.isHost（host_id/userId 偶发类型不一致导致误判 false 只走 leave，
      // 表现就是"用户端挂机后 PC 还停在会议里"）。
      console.log('[CloudLawVideoCall] hangup -> endMeeting', {
        roomCode: meetingStore.currentRoom?.room_code,
        hostId: meetingStore.currentRoom?.host_id,
        myUserId: userStore.userInfo?.id || userStore.userInfo?.user_id,
        isHostComputed: meetingStore.isHost
      })
      await meetingStore.endMeeting()
    }
  } catch (err) {
    console.warn('[CloudLawVideoCall] end/hangup failed, fallback leave', err)
    // 失败时兜底 leave，至少自己先退出，避免卡死
    try { await meetingStore.leave() } catch (leaveErr) { console.warn('[CloudLawVideoCall] fallback leave failed', leaveErr) }
  } finally {
    ended.value = true
    leaving.value = false
    if (isLawRole.value) {
      notifyMiniProgramCallEndedOnce()
    } else {
      // 用户端挂断：统一走 notifyMiniProgramCallEndedOnce()，
      //   它已改为「优先 wx.miniProgram 直达咨询详情 returnUrl（reLaunch 清栈→承载页 onUnload 调 /end 收尾），
      //   URL 桥接仅作兜底」。避免之前 URL-桥接优先（onWebViewLoad 在 hash SPA 内不触发）+ navigateBack
      //   只返回上一页、跳不到咨询详情的问题。
      notifyMiniProgramCallEndedOnce()
    }
  }
}

onLoad(query => {
  const decodeQueryValue = (value) => {
    let text = String(value || '').trim()
    if (!text) return ''
    for (let i = 0; i < 3; i++) {
      try {
        const next = decodeURIComponent(text)
        if (next === text) break
        text = next
      } catch (e) {
        break
      }
    }
    return text
  }
  rawCode.value = query?.code || query?.roomCode || ''
  callId.value = query?.callId || query?.call_id || ''
  consultId.value = decodeQueryValue(query?.consultId || query?.consult_id || '')
  miniProgramReturnUrl.value = decodeQueryValue(query?.miniProgramReturnUrl || query?.mini_program_return_url || '')
  cloudLawApiBase.value = query?.cloudLawApiBase || query?.cloud_law_api_base || ''
  cloudLawToken.value = query?.cloudLawToken || query?.cloud_law_token || ''
  cloudLawRole.value = query?.cloudLawRole || query?.cloud_law_role || ''
  // #ifdef H5
  try {
    if (consultId.value) {
      window.sessionStorage.setItem('echo_cloud_law_consult_id', consultId.value)
    }
    if (miniProgramReturnUrl.value) {
      window.sessionStorage.setItem('echo_cloud_law_return_url', miniProgramReturnUrl.value)
    }
  } catch (e) {}
  if (String(cloudLawRole.value).toLowerCase() === 'law') {
    try {
      window.sessionStorage.setItem('echo_cloud_law_meeting_role', 'law')
    } catch (e) {}
  }
  // #endif
  const mode = String(query?.mode || query?.callType || query?.type || '').toLowerCase()
  callMode.value = mode === 'voice' || mode === 'audio' ? 'voice' : 'video'
  joinMeeting()
})

onMounted(() => {
  stateStopWatch = watch(() => meetingStore.localState, s => {
    if (s === MEETING_LOCAL_STATE_CONNECTED) {
      startTimer()
      scheduleVideoRetry()
      startCommandPolling()
    }
    if (s === MEETING_LOCAL_STATE_ENDED) {
      stopCommandPolling()
      // 律师端仅「返回上一页」最小化：不结束云律通话、不 postMessage 通知小程序通话结束
      if (minimizingToMiniProgram.value) {
        minimizingToMiniProgram.value = false
        return
      }
      const reason = meetingStore.lastEndedReason
      const label = MEETING_ENDED_REASON_LABEL?.[reason] || '视频咨询已结束'
      uni.showToast({ title: label, icon: 'none' })
      ended.value = true
      // 对方挂断或会议结束时，通知云律后端 + 父页面（PC 律师浮窗）+ 小程序
      notifyCloudLawEnd().finally(() => {
        if (isLawRole.value) {
          notifyCloudLawParentCallEnded(callId.value)
          if (isCloudLawPcIframe()) return
        }
        notifyMiniProgramCallEndedOnce()
        scheduleEndedRedirect()
      })
    }
  }, { immediate: true })
})

watch(ended, (value) => {
  if (!value || isLawRole.value) return
  setTimeout(() => {
    if (ended.value && !miniProgramRedirected) {
      notifyMiniProgramCallEndedOnce()
      if (!miniProgramRedirected) {
        goToConsultDetail()
      }
    }
  }, 800)
})

onBeforeUnmount(() => {
  if (endedRedirectTimer) {
    clearTimeout(endedRedirectTimer)
    endedRedirectTimer = null
  }
  if (miniProgramRedirectRetryTimer) {
    clearInterval(miniProgramRedirectRetryTimer)
    miniProgramRedirectRetryTimer = null
  }
  if (timerHandle) {
    clearInterval(timerHandle)
    timerHandle = null
  }
  if (stateStopWatch) {
    stateStopWatch()
    stateStopWatch = null
  }
  if (videoRetryHandle) {
    clearTimeout(videoRetryHandle)
    videoRetryHandle = null
  }
  stopCommandPolling()
})

onUnload(() => {
  stopCommandPolling()
  if (isLawRole.value && !ended.value && !userInitiatedHangup.value) {
    minimizingToMiniProgram.value = true
    if (meetingStore.isInMeeting && meetingStore.localState !== MEETING_LOCAL_STATE_LEAVING) {
      meetingStore.leave().catch(() => {})
    }
    return
  }
  if (!ended.value && callId.value) {
    notifyCloudLawEnd().catch(() => {})
  }
  if (meetingStore.isInMeeting && meetingStore.localState !== MEETING_LOCAL_STATE_LEAVING) {
    meetingStore.leave().catch(() => {})
  }
})
</script>

<style scoped>
.cloud-law-call {
  position: fixed;
  inset: 0;
  width: 100vw;
  height: 100vh;
  overflow: hidden;
  /* [视频咨询 2026-05-24] 背景统一为静态 bg.png（前后两屏视觉一致） */
  background: #050b1d url('/static/bg.png') center center / cover no-repeat;
  color: #ffffff;
  z-index: 1000;
}
.entry-state {
  height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 48rpx;
  box-sizing: border-box;
  text-align: center;
}
.entry-avatar {
  width: 136rpx;
  height: 136rpx;
  border-radius: 50%;
  background: linear-gradient(135deg, #43b1ff 0%, #1974e8 100%);
  border: 4rpx solid rgba(255, 255, 255, 0.75);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 56rpx;
  font-weight: 700;
  box-shadow: 0 18rpx 60rpx rgba(25, 116, 232, 0.36);
}
.entry-title {
  margin-top: 28rpx;
  font-size: 34rpx;
  font-weight: 700;
}
.entry-desc {
  margin-top: 14rpx;
  max-width: 560rpx;
  font-size: 24rpx;
  line-height: 1.5;
  color: rgba(255, 255, 255, 0.72);
}
.retry-btn {
  margin-top: 36rpx;
  width: 260rpx;
  height: 76rpx;
  line-height: 76rpx;
  border-radius: 999rpx;
  background: #1664ff;
  color: #ffffff;
  font-size: 28rpx;
}
.call-shell {
  position: relative;
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
}
.remote-stage {
  position: absolute;
  inset: 0;
  background: #050b1d;
}
.voice-stage {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: flex-start;
  justify-content: center;
  overflow: hidden;
}
.voice-bg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}
.voice-profile {
  position: relative;
  z-index: 2;
  margin-top: calc(126rpx + env(safe-area-inset-top));
  display: flex;
  flex-direction: column;
  align-items: center;
}
.voice-avatar {
  width: 132rpx;
  height: 132rpx;
  border-radius: 50%;
  border: 4rpx solid rgba(255, 255, 255, 0.86);
  box-shadow: 0 12rpx 36rpx rgba(0, 0, 0, 0.28);
}
.voice-avatar-fallback {
  display: flex;
  align-items: center;
  justify-content: center;
  background: linear-gradient(135deg, #43b1ff 0%, #1974e8 100%);
  color: #ffffff;
  font-size: 54rpx;
  font-weight: 700;
}
.voice-name {
  margin-top: 24rpx;
  font-size: 28rpx;
  line-height: 1;
  color: #ffffff;
  font-weight: 600;
}
.main-video {
  width: 100%;
  height: 100%;
  border-radius: 0;
}
.empty-video {
  width: 100%;
  height: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}
.self-preview {
  position: absolute;
  right: 30rpx;
  top: calc(104rpx + env(safe-area-inset-top));
  width: 220rpx;
  height: 310rpx;
  border-radius: 10rpx;
  overflow: hidden;
  border: 0;
  box-shadow: 0 10rpx 40rpx rgba(0, 0, 0, 0.38);
  background: #0b1220;
  z-index: 5;
}
.self-video {
  width: 100%;
  height: 100%;
  border-radius: 0;
}
.video-back {
  font-size: 56rpx;
  line-height: 1;
  color: #ffffff;
  padding: 0 12rpx;
  flex-shrink: 0;
}
.video-topbar {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  height: calc(88rpx + env(safe-area-inset-top));
  padding: env(safe-area-inset-top) 46rpx 0;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16rpx;
  background: linear-gradient(180deg, rgba(0, 0, 0, 0.55) 0%, rgba(0, 0, 0, 0) 100%);
  box-sizing: border-box;
  z-index: 6;
}
.top-window-icon {
  position: relative;
  width: 42rpx;
  height: 42rpx;
  border: 4rpx solid rgba(255, 255, 255, 0.9);
  border-radius: 6rpx;
  box-sizing: border-box;
}
.top-window-icon::after {
  content: '';
  position: absolute;
  right: -16rpx;
  bottom: -14rpx;
  width: 24rpx;
  height: 24rpx;
  border: 4rpx solid rgba(255, 255, 255, 0.9);
  border-radius: 5rpx;
  box-sizing: border-box;
}
.top-duration {
  font-size: 36rpx;
  color: #ffffff;
  font-weight: 500;
  text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.35);
}
.top-plus {
  color: #ffffff;
  font-size: 56rpx;
  line-height: 1;
  font-weight: 300;
}
.voice-topbar {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  height: calc(88rpx + env(safe-area-inset-top));
  padding: env(safe-area-inset-top) 26rpx 0;
  display: flex;
  align-items: center;
  box-sizing: border-box;
  z-index: 6;
}
.voice-back {
  color: #ffffff;
  font-size: 54rpx;
  line-height: 1;
  font-weight: 300;
}
.voice-duration {
  position: absolute;
  left: 50%;
  bottom: calc(310rpx + env(safe-area-inset-bottom));
  transform: translateX(-50%);
  padding: 10rpx 20rpx;
  border-radius: 999rpx;
  background: rgba(255, 255, 255, 0.16);
  color: rgba(255, 255, 255, 0.78);
  font-size: 24rpx;
  line-height: 1;
  z-index: 7;
}
.controls {
  position: absolute;
  left: 0;
  right: 0;
  bottom: calc(112rpx + env(safe-area-inset-bottom));
  box-sizing: border-box;
  z-index: 8;
}
.video-controls {
  padding: 0 58rpx;
  display: flex;
  flex-direction: column;
  gap: 30rpx;
  background: transparent;
}
.voice-controls {
  padding: 0 58rpx;
  display: flex;
  flex-direction: column;
  gap: 30rpx;
  background: transparent;
}
.controls-row {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  align-items: end;
  column-gap: 44rpx;
}
.controls-row-bottom {
  align-items: end;
}
.control-item {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 18rpx;
}
.control-btn,
.hangup-btn {
  width: 150rpx;
  height: 150rpx;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
}
.white-btn {
  background: rgba(255, 255, 255, 0.96);
  box-shadow: 0 10rpx 26rpx rgba(0, 0, 0, 0.18);
}
.dark-btn {
  width: 132rpx;
  height: 132rpx;
  background: rgba(22, 43, 105, 0.86);
  border: 1rpx solid rgba(255, 255, 255, 0.06);
  box-shadow: inset 0 0 0 2rpx rgba(255, 255, 255, 0.04);
}
.control-btn.disabled {
  opacity: 0.94;
}
.control-btn.active {
  background: linear-gradient(180deg, #4f8dff, #2563eb);
  border-color: rgba(79, 141, 255, 0.55);
}
.hangup-btn {
  width: 150rpx;
  height: 150rpx;
  background: #ff2f3f;
  box-shadow: 0 14rpx 36rpx rgba(255, 47, 63, 0.38);
}
.control-image {
  width: 170rpx;
  height: 170rpx;
}
.dark-btn .control-image {
  width: 150rpx;
  height: 150rpx;
}
.text-icon-btn .control-image {
  width: 64rpx;
  height: 64rpx;
}
.text-icon-btn {
  color: #ffffff;
}
.text-icon {
  font-size: 54rpx;
  line-height: 1;
  color: #ffffff;
  font-weight: 700;
}
.hangup-image {
  width: 150rpx;
  height: 150rpx;
}
.control-label {
  font-size: 24rpx;
  line-height: 1;
  color: rgba(255, 255, 255, 0.82);
  text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
}
.loading {
  opacity: 0.68;
}
.snapshot-preview-mask {
  position: fixed;
  left: 0;
  right: 0;
  top: 0;
  bottom: 0;
  z-index: 99;
  background: rgba(0, 0, 0, 0.78);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 36rpx;
  box-sizing: border-box;
}
.snapshot-preview-panel {
  width: 100%;
  max-width: 720rpx;
  max-height: 88vh;
  border-radius: 24rpx;
  background: #111827;
  padding: 24rpx;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  gap: 20rpx;
}
.snapshot-preview-image {
  width: 100%;
  height: 58vh;
  border-radius: 16rpx;
  background: #000000;
}
.snapshot-preview-tip {
  color: rgba(255, 255, 255, 0.82);
  font-size: 24rpx;
  line-height: 1.45;
  text-align: center;
}
.snapshot-preview-actions {
  display: flex;
  gap: 18rpx;
}
.snapshot-preview-btn {
  flex: 1;
  height: 72rpx;
  line-height: 72rpx;
  border-radius: 999rpx;
  color: #ffffff;
  background: #2563eb;
  font-size: 26rpx;
}
.snapshot-preview-btn.secondary {
  color: rgba(255, 255, 255, 0.86);
  background: rgba(255, 255, 255, 0.14);
}
.clean-video-tile :deep(.footer) {
  display: none;
}
.clean-video-tile :deep(.tag-self) {
  display: none;
}
.clean-video-tile :deep(.tag-host) {
  display: none;
}
</style>
