<script setup>
/**
 * Shows the Synology Surveillance recording captured around the moment a
 * shipment was commissioned. The modal flow:
 *   1. Open      → call /api/surveillance/shipment-recording/init
 *      (the server downloads from the NAS, this can take a while for
 *      100MB+ files — show a loading animation in the meantime)
 *   2. Got token → render <video> pointing at the stream endpoint, plus
 *      an event marker strip (item_scanned, paket_selected, …) that lets
 *      the user click straight to a specific moment
 *   3. Close     → DELETE the cached file via the cleanup endpoint
 */

const props = defineProps({
  modelValue: { type: Boolean, default: false },
  shipmentId: { type: [String, Number], default: '' },
  documentNo: { type: String, default: '' }
})
const emit = defineEmits(['update:modelValue'])

const loading = ref(false)
const errorMessage = ref('')
const token = ref('')
const sizeBytes = ref(0)
const commissionTs = ref(0)
const recordingStartTs = ref(0)
const fromTs = ref(0)
const toTs = ref(0)
const events = ref([])
const windowSource = ref('')
const videoEl = ref(null)
const videoDuration = ref(0)
const currentTime = ref(0)
const selectedMarker = ref(null)

// Optional second camera (tables configured with synology_camera_2_id). The
// primary camera keeps all the existing behaviour; the second is an add-on
// player that plays the same window and follows marker seeks.
const primaryCameraId = ref(0)
const secondToken = ref('')
const secondSizeBytes = ref(0)
const secondCameraId = ref(0)
const secondVideoEl = ref(null)

const streamUrl = computed(() =>
  token.value ? `/api/surveillance/shipment-recording/stream?token=${token.value}` : ''
)
const secondStreamUrl = computed(() =>
  secondToken.value ? `/api/surveillance/shipment-recording/stream?token=${secondToken.value}` : ''
)
const hasTwoCameras = computed(() => !!secondStreamUrl.value)

// Filename for the download — uses the shipment's DocumentNo when we
// have it, falls back to the shipment id, then a generic timestamp.
const downloadFilename = computed(() => {
  const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
  const base = props.documentNo || props.shipmentId || stamp
  return `commissioning-${base}.mp4`
})

// Per-camera download filename — appends the camera id when there are two,
// so the two clips don't overwrite each other.
const downloadNameFor = (camId) => {
  const base = props.documentNo || props.shipmentId || 'clip'
  return camId ? `commissioning-${base}-cam${camId}.mp4` : `commissioning-${base}.mp4`
}

const formattedSize = computed(() => {
  if (!sizeBytes.value) return ''
  const mb = sizeBytes.value / (1024 * 1024)
  return mb >= 1024 ? `${(mb / 1024).toFixed(2)} GB` : `${mb.toFixed(1)} MB`
})

// Total clip duration in seconds, preferring the metadata duration but
// falling back to the [from, to] window we requested if metadata isn't
// loaded yet (Synology mp4s without faststart can be slow to report it).
const clipDurationSec = computed(() => {
  if (Number.isFinite(videoDuration.value) && videoDuration.value > 0) {
    return videoDuration.value
  }
  if (toTs.value > fromTs.value) {
    return toTs.value - fromTs.value
  }
  return 0
})

// Convert an absolute UTC timestamp into "seconds into the clip", clamped
// to the clip range. recordingStartTs is the wall-clock start of the slice
// the NAS handed us (which may differ from the requested fromTs if the
// previous 30-min file ended after fromTs).
const tsToClipOffset = (ts) => {
  if (!ts || !recordingStartTs.value) return null
  return Math.max(0, ts - recordingStartTs.value)
}

const formatClock = (sec) => {
  if (!Number.isFinite(sec) || sec < 0) return '0:00'
  const m = Math.floor(sec / 60)
  const s = Math.floor(sec % 60)
  return `${m}:${String(s).padStart(2, '0')}`
}

const formatAbsTime = (unix) => {
  if (!unix) return ''
  try {
    return new Date(unix * 1000).toLocaleTimeString([], { hour12: false })
  } catch {
    return ''
  }
}

// Render iDempiere ActionTime (real UTC `Z` string) as Berlin local
// time in the German "DD.MM.YYYY, HH:MM:SS" form expected on the floor.
const formatDeviceTime = (iso) => {
  if (!iso) return ''
  const d = new Date(iso)
  if (Number.isNaN(d.getTime())) return iso
  return d.toLocaleString('de-DE', {
    timeZone: 'Europe/Berlin',
    year: 'numeric', month: '2-digit', day: '2-digit',
    hour: '2-digit', minute: '2-digit', second: '2-digit',
    hour12: false,
  })
}

// Map raw event ActionType → display label + tone class (controls colour).
const EVENT_STYLES = {
  session_start:    { label: 'Start',           tone: 'tone-start' },
  session_complete: { label: 'Complete',        tone: 'tone-complete' },
  item_scanned:     { label: 'Item scanned',    tone: 'tone-scan' },
  paket_selected:   { label: 'Parcel selected', tone: 'tone-paket' },
  label_requested:  { label: 'Label requested', tone: 'tone-label' },
  label_succeeded:  { label: 'Label succeeded', tone: 'tone-label' },
}
const styleFor = (type) => EVENT_STYLES[type] || { label: type || 'Event', tone: 'tone-other' }

// Build the renderable marker list: each event becomes a tiny dot at a
// percentage offset into the clip. Rendered uniformly — same shape, same
// size, no stacking — like the lone session_start dot. Events that share
// a `Created` (committed in the same DB transaction) end up overlapping at
// the same x; the detail panel still surfaces the clicked event's data.
const markers = computed(() => {
  const dur = clipDurationSec.value
  if (!dur) return []
  return events.value
    .map((e, idx) => {
      // Prefer the server-computed clipOffsetSec — it accounts for
      // motion-detection gaps that disappear from the merged MP4.
      // Fall back to wall-clock math for older responses that don't
      // carry the field (will be slightly off when chunks have gaps).
      const offset = Number.isFinite(e.clipOffsetSec)
        ? e.clipOffsetSec
        : tsToClipOffset(e.ts)
      if (offset === null || offset === undefined || offset > dur) return null
      const sty = styleFor(e.type)
      return {
        idx,
        event: e,
        type: e.type,
        label: sty.label,
        tone: sty.tone,
        description: e.description,
        offsetSec: offset,
        leftPct: (offset / dur) * 100,
        absTime: formatAbsTime(e.ts),
      }
    })
    .filter(Boolean)
})

const reset = () => {
  loading.value = false
  errorMessage.value = ''
  token.value = ''
  sizeBytes.value = 0
  secondToken.value = ''
  secondSizeBytes.value = 0
  secondCameraId.value = 0
  primaryCameraId.value = 0
  commissionTs.value = 0
  recordingStartTs.value = 0
  fromTs.value = 0
  toTs.value = 0
  events.value = []
  windowSource.value = ''
  videoDuration.value = 0
  currentTime.value = 0
  selectedMarker.value = null
  productImageUrl.value = ''
  productImageCache.value = {}
}

const selectMarker = (marker) => {
  selectedMarker.value = marker
  seekTo(marker.offsetSec)
}

const fetchRecording = async () => {
  if (!props.shipmentId) return
  reset()
  loading.value = true
  try {
    const res = await $fetch('/api/surveillance/shipment-recording/init', {
      method: 'POST',
      headers: useRequestHeaders(['cookie']),
      body: { shipmentId: props.shipmentId }
    })
    if (res?.token) {
      const cl = Array.isArray(res.clips) ? res.clips : []
      // Primary camera (clip 0) — unchanged behaviour.
      token.value = res.token
      sizeBytes.value = res.sizeBytes || cl[0]?.sizeBytes || 0
      primaryCameraId.value = cl[0]?.cameraId || res.cameraIds?.[0] || 0
      // Optional second camera (clip 1).
      secondToken.value = cl[1]?.token || ''
      secondSizeBytes.value = cl[1]?.sizeBytes || 0
      secondCameraId.value = cl[1]?.cameraId || res.cameraIds?.[1] || 0
      commissionTs.value = res.commissionTs || 0
      recordingStartTs.value = res.recordingStartTs || 0
      fromTs.value = res.fromTs || 0
      toTs.value = res.toTs || 0
      events.value = Array.isArray(res.events) ? res.events : []
      windowSource.value = res.windowSource || ''
    } else {
      errorMessage.value = 'No recording was returned.'
    }
  } catch (err) {
    errorMessage.value =
      err?.statusMessage || err?.data?.statusMessage || err?.message || 'Failed to load recording.'
  } finally {
    loading.value = false
  }
}

const deleteToken = async (t) => {
  if (!t) return
  try {
    await $fetch('/api/surveillance/shipment-recording/cleanup', {
      method: 'DELETE',
      headers: useRequestHeaders(['cookie']),
      query: { token: t }
    })
  } catch {
    // Best-effort; the TTL fallback on the server will sweep it eventually.
  }
}

const cleanup = async () => {
  const t = token.value
  const t2 = secondToken.value
  token.value = ''
  secondToken.value = ''
  await Promise.all([deleteToken(t), deleteToken(t2)])
}

const close = async () => {
  // Pause the players so the browser releases the streams before we delete.
  try { videoEl.value?.pause() } catch { /* noop */ }
  try { secondVideoEl.value?.pause() } catch { /* noop */ }
  await cleanup()
  reset()
  emit('update:modelValue', false)
}

const onEnded = () => {
  // Drop the file as soon as playback finishes; user can click X to dismiss.
  // With two cameras the other player may still be running, so defer cleanup
  // to close()/unmount/TTL in that case.
  if (!hasTwoCameras.value) cleanup()
}

const onLoadedMetadata = () => {
  if (videoEl.value && Number.isFinite(videoEl.value.duration)) {
    videoDuration.value = videoEl.value.duration
  }
}

const onTimeUpdate = () => {
  if (videoEl.value) currentTime.value = videoEl.value.currentTime || 0
}

// Auto-select the marker the playhead is currently passing through
// (latest one whose offsetSec ≤ currentTime). Updates the detail panel
// in real time as the video plays. Manual marker clicks still work —
// they call seekTo(), which moves currentTime, which then re-triggers
// this watcher and lands on the same marker.
watch([currentTime, () => markers.value], ([t, list]) => {
  if (!list || !list.length) return
  let best = null
  for (const m of list) {
    if (m.offsetSec <= t) {
      if (!best || m.offsetSec > best.offsetSec) best = m
    }
  }
  if (best && (!selectedMarker.value || selectedMarker.value.idx !== best.idx)) {
    selectedMarker.value = best
  }
})

// --- Strapi product image -------------------------------------------------
// Fetched on demand when the auto-selected marker is an item_scanned event
// with a Strapi product id. Cached per session so seeking back to the same
// product doesn't refetch.
const config = useRuntimeConfig()
const productImageCache = ref({})
const productImageUrl = ref('')
const productImageLoading = ref(false)

const buildStrapiThumbUrl = (image) => {
  const f = image?.formats?.thumbnail || image
  if (!f?.hash || !f?.ext) return ''
  return `${config.public.strapi}/files-api/${f.hash}${f.ext}`
}

watch(() => selectedMarker.value, async (m) => {
  productImageUrl.value = ''
  if (!m || m.event?.type !== 'item_scanned') return
  const docId = m.event?.productStrapiDocumentId
  if (!docId) return
  if (productImageCache.value[docId] !== undefined) {
    productImageUrl.value = productImageCache.value[docId]
    return
  }
  productImageLoading.value = true
  try {
    const res = await $fetch(`/api/materials/products/${docId}/galleries`)
    const galleries = res?.data?.image ?? []
    const mainId = res?.data?.Main_Image
    const chosen = (mainId ? galleries.find((g) => g.id == mainId) : null) ?? galleries[0]
    const url = buildStrapiThumbUrl(chosen)
    productImageCache.value[docId] = url
    productImageUrl.value = url
  } catch {
    productImageCache.value[docId] = ''
  } finally {
    productImageLoading.value = false
  }
})

const seekTo = (offsetSec) => {
  if (!Number.isFinite(offsetSec)) return
  const target = Math.max(0, offsetSec)
  // Seek both cameras to the same offset — they share the export window.
  for (const el of [videoEl.value, secondVideoEl.value]) {
    if (!el) continue
    try {
      el.currentTime = target
      if (el.paused) el.play().catch(() => null)
    } catch { /* noop */ }
  }
}

watch(() => props.modelValue, (open, wasOpen) => {
  if (open && !wasOpen) {
    fetchRecording()
  } else if (!open && wasOpen) {
    cleanup()
    reset()
  }
})

onBeforeUnmount(() => {
  if (token.value) cleanup()
})
</script>

<template>
  <div class="modal" :class="{ 'active': modelValue }" :style="{ display: modelValue ? 'block' : 'none' }">
    <div class="modalDialog modalCenter" @click.self="close">
      <div class="modalContent video-modal-content" :class="{ 'is-dual': hasTwoCameras }">
        <div class="modalHeader">
          <h3 class="modalTitle">
            Commissioning Video
            <span v-if="documentNo" class="video-modal-doc">— {{ documentNo }}</span>
          </h3>
          <div class="cursor-pointer" @click="close">
            <span class="tf-icon ti ti-x ti-sm me-1"></span>
          </div>
        </div>

        <div class="modalBody video-modal-body">
          <div v-if="loading" class="video-loading">
            <div class="video-spinner"></div>
            <div class="video-loading-text">
              Stream recording from LogShip Media System…
              <div class="video-loading-sub">This may take a moment for large files.</div>
            </div>
          </div>

          <div v-else-if="errorMessage" class="video-error">
            <span class="tf-icon ti ti-alert-triangle ti-sm me-1"></span>
            {{ errorMessage }}
            <div class="mt-3">
              <button type="button" class="button" @click="fetchRecording">Retry</button>
            </div>
          </div>

          <div v-else-if="streamUrl" class="video-wrap">
            <div class="video-players" :class="{ 'is-dual': hasTwoCameras }">
              <div class="video-player-col">
                <div v-if="hasTwoCameras" class="video-cam-label">
                  <span class="tf-icon ti ti-video me-1"></span> Camera {{ primaryCameraId }}
                </div>
                <video
                  ref="videoEl"
                  :src="streamUrl"
                  controls
                  autoplay
                  playsinline
                  class="video-player"
                  @ended="onEnded"
                  @loadedmetadata="onLoadedMetadata"
                  @durationchange="onLoadedMetadata"
                  @timeupdate="onTimeUpdate"
                />
              </div>
              <div v-if="hasTwoCameras" class="video-player-col">
                <div class="video-cam-label">
                  <span class="tf-icon ti ti-video me-1"></span> Camera {{ secondCameraId }}
                </div>
                <video
                  ref="secondVideoEl"
                  :src="secondStreamUrl"
                  controls
                  autoplay
                  playsinline
                  class="video-player"
                />
              </div>
            </div>

            <div v-if="markers.length" class="video-timeline">
              <div class="video-timeline-track">
                <div
                  class="video-timeline-progress"
                  :style="{ width: clipDurationSec ? (currentTime / clipDurationSec) * 100 + '%' : '0%' }"
                ></div>
                <button
                  v-for="(m, idx) in markers"
                  :key="idx"
                  type="button"
                  class="video-marker"
                  :class="[m.tone, { 'is-selected': selectedMarker && selectedMarker.idx === m.idx }]"
                  :style="{ left: m.leftPct + '%' }"
                  :title="`${m.label}${m.description ? ' — ' + m.description : ''}\n${m.absTime} (${formatClock(m.offsetSec)} into clip)`"
                  @click="selectMarker(m)"
                ></button>
              </div>
              <div class="video-timeline-legend">
                <span v-for="(g, gi) in [
                  { tone: 'tone-start',    label: 'Start' },
                  { tone: 'tone-scan',     label: 'Scan' },
                  { tone: 'tone-paket',    label: 'Parcel' },
                  { tone: 'tone-label',    label: 'Label' },
                  { tone: 'tone-complete', label: 'Complete' },
                ]" :key="gi" class="video-legend-item">
                  <span class="video-legend-dot" :class="g.tone"></span>{{ g.label }}
                </span>
              </div>
            </div>

            <div class="video-meta">
              <span v-if="formattedSize">{{ formattedSize }}</span>
              <span v-if="markers.length">{{ markers.length }} event{{ markers.length === 1 ? '' : 's' }} — click a marker for details</span>
              <span v-if="windowSource === 'shipping-date'" class="video-meta-warn" title="No commissioning activity records — using shipment shipping_date instead.">
                fallback window
              </span>
            </div>

            <!-- Details panel: shown when a marker is selected. Surfaces the
                 product (for item_scanned), shipping carrier (for label_*),
                 description / parcel id, and exact wall-clock + clip offsets. -->
            <div v-if="selectedMarker" class="video-event-detail">
              <div class="video-event-detail__head">
                <span class="video-event-detail__dot" :class="selectedMarker.tone"></span>
                <strong>{{ selectedMarker.label }}</strong>
                <span class="video-event-detail__time">
                  {{ selectedMarker.absTime }}
                  <span class="video-event-detail__sub">({{ formatClock(selectedMarker.offsetSec) }} into clip)</span>
                </span>
                <button type="button" class="video-event-detail__close" @click="selectedMarker = null" title="Dismiss">
                  <span class="tf-icon ti ti-x ti-xs"></span>
                </button>
              </div>
              <!-- Product thumbnail for item_scanned events. The image
                   loads asynchronously from Strapi when the marker becomes
                   the auto-selected one (or the user clicks). -->
              <div
                v-if="selectedMarker.event.type === 'item_scanned' && (productImageUrl || productImageLoading)"
                class="video-event-detail__image"
              >
                <img
                  v-if="productImageUrl"
                  :src="productImageUrl"
                  :alt="selectedMarker.event.productName || 'Product'"
                />
                <div v-else class="video-event-detail__image-skeleton">…</div>
              </div>
              <dl class="video-event-detail__grid">
                <template v-if="selectedMarker.event.type">
                  <dt>Event type</dt>
                  <dd><code>{{ selectedMarker.event.type }}</code></dd>
                </template>
                <template v-if="selectedMarker.event.productName">
                  <dt>Product</dt>
                  <dd>{{ selectedMarker.event.productName }}</dd>
                </template>
                <template v-if="selectedMarker.event.productSku">
                  <dt>SKU</dt>
                  <dd>{{ selectedMarker.event.productSku }}</dd>
                </template>
                <template v-if="selectedMarker.event.productUpc">
                  <dt>UPC</dt>
                  <dd>{{ selectedMarker.event.productUpc }}</dd>
                </template>
                <template v-if="selectedMarker.event.productValue">
                  <dt>Article No.</dt>
                  <dd>{{ selectedMarker.event.productValue }}</dd>
                </template>
                <template v-if="selectedMarker.event.qty != null">
                  <dt>Quantity</dt>
                  <dd>{{ selectedMarker.event.qty }}</dd>
                </template>
                <template v-if="selectedMarker.event.shippingService">
                  <dt>Carrier</dt>
                  <dd>{{ selectedMarker.event.shippingService }}</dd>
                </template>
                <template v-if="selectedMarker.event.description">
                  <dt>Description</dt>
                  <dd>{{ selectedMarker.event.description }}</dd>
                </template>
                <template v-if="selectedMarker.event.durationMs > 0">
                  <dt>Duration</dt>
                  <dd>{{ (selectedMarker.event.durationMs / 1000).toFixed(1) }} s</dd>
                </template>
                <template v-if="selectedMarker.event.actionTime">
                  <dt>Device time</dt>
                  <dd>{{ formatDeviceTime(selectedMarker.event.actionTime) }}</dd>
                </template>
              </dl>
            </div>
          </div>
        </div>

        <div class="modalFooter">
          <div></div>
          <div class="d-flex gap-10px">
            <a
              v-if="streamUrl"
              :href="streamUrl"
              :download="downloadNameFor(hasTwoCameras ? primaryCameraId : 0)"
              class="button is-link"
              title="Download this camera's clip as MP4"
            >
              <i class="ti ti-download mr-5px"></i>
              {{ hasTwoCameras ? `Camera ${primaryCameraId}` : 'Download' }}
            </a>
            <a
              v-if="hasTwoCameras && secondStreamUrl"
              :href="secondStreamUrl"
              :download="downloadNameFor(secondCameraId)"
              class="button is-link"
              title="Download this camera's clip as MP4"
            >
              <i class="ti ti-download mr-5px"></i>
              Camera {{ secondCameraId }}
            </a>
            <button type="button" class="button" @click="close">Close</button>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<style scoped>
.video-modal-content {
  width: min(960px, 92vw);
  max-width: 960px;
}
/* Two cameras → wider modal so both players have room. */
.video-modal-content.is-dual {
  width: min(1320px, 95vw);
  max-width: 1320px;
}

/* Player layout: single = full width; dual = side by side, wrapping on narrow. */
.video-players {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
}
.video-players.is-dual {
  flex-direction: row;
  flex-wrap: wrap;
}
.video-player-col {
  flex: 1 1 0;
  min-width: 280px;
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
}
.video-cam-label {
  display: inline-flex;
  align-items: center;
  font-size: 0.75rem;
  font-weight: 700;
  letter-spacing: 0.03em;
  text-transform: uppercase;
  color: #485fc7;
}
.video-modal-doc {
  font-weight: 400;
  opacity: 0.7;
  margin-left: 0.5rem;
}
.video-modal-body {
  min-height: 360px;
  display: flex;
  align-items: center;
  justify-content: center;
}
.video-wrap {
  width: 100%;
}
.video-player {
  width: 100%;
  max-height: 65vh;
  background: #000;
  border-radius: 6px;
  display: block;
}

/* Marker strip below the video. The track represents the entire clip's
   timeline; markers are absolutely positioned by percentage. */
.video-timeline {
  margin-top: 0.5rem;
}
.video-timeline-track {
  position: relative;
  height: 18px;
  border-radius: 9px;
  background: rgba(127, 127, 127, 0.18);
  overflow: visible;
}
.video-timeline-progress {
  position: absolute;
  top: 0; left: 0;
  height: 100%;
  background: rgba(99, 102, 241, 0.18);
  border-radius: 9px;
  pointer-events: none;
  transition: width 0.1s linear;
}
/* All markers render uniformly — same tiny dot as the session_start marker.
   No stacking; events that share a Created overlap at the same x. */
.video-marker {
  position: absolute;
  top: 50%;
  left: 0;
  transform: translate(-50%, -50%);
  width: 7px;
  height: 7px;
  border: 1.5px solid #fff;
  border-radius: 50%;
  padding: 0;
  cursor: pointer;
  box-shadow: 0 1px 2px rgba(0,0,0,0.25);
  transition: width 0.1s, height 0.1s, box-shadow 0.1s;
}
.video-marker:hover {
  width: 11px;
  height: 11px;
  box-shadow: 0 2px 6px rgba(0,0,0,0.4);
  z-index: 2;
}
.video-marker.is-selected {
  outline: 2px solid #1f2937;
  outline-offset: 1px;
  z-index: 3;
}
.video-marker.tone-start    { background: #16a34a; }   /* green */
.video-marker.tone-complete { background: #0ea5e9; }   /* sky */
.video-marker.tone-scan     { background: #f59e0b; }   /* amber */
.video-marker.tone-paket    { background: #a855f7; }   /* violet */
.video-marker.tone-label    { background: #ec4899; }   /* pink */
.video-marker.tone-other    { background: #64748b; }   /* slate */

.video-timeline-legend {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem;
  margin-top: 0.4rem;
  font-size: 0.7rem;
  opacity: 0.75;
}
.video-legend-item { display: inline-flex; align-items: center; gap: 4px; }
.video-legend-dot {
  display: inline-block;
  width: 10px;
  height: 10px;
  border-radius: 3px;
}
.video-legend-dot.tone-start    { background: #16a34a; }
.video-legend-dot.tone-complete { background: #0ea5e9; }
.video-legend-dot.tone-scan     { background: #f59e0b; }
.video-legend-dot.tone-paket    { background: #a855f7; }
.video-legend-dot.tone-label    { background: #ec4899; }

.video-meta {
  display: flex;
  gap: 1rem;
  justify-content: space-between;
  margin-top: 0.5rem;
  font-size: 0.8rem;
  opacity: 0.7;
}
.video-meta-warn {
  color: #b45309;
  font-weight: 600;
}

/* Selected marker → detail panel pinned to the bottom of the modal body.
   Slightly tinted + accent border so it reads as the active "what did I
   just click?" area, not just another meta row. */
.video-event-detail {
  margin-top: 0.75rem;
  border: 1px solid rgba(99,102,241,0.35);
  border-left: 3px solid rgba(99,102,241,0.7);
  border-radius: 6px;
  padding: 0.7rem 0.9rem;
  background: rgba(99,102,241,0.06);
}
.video-event-detail__head {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  font-size: 0.9rem;
}
.video-event-detail__dot {
  width: 10px; height: 10px; border-radius: 50%;
}
.video-event-detail__dot.tone-start    { background: #16a34a; }
.video-event-detail__dot.tone-complete { background: #0ea5e9; }
.video-event-detail__dot.tone-scan     { background: #f59e0b; }
.video-event-detail__dot.tone-paket    { background: #a855f7; }
.video-event-detail__dot.tone-label    { background: #ec4899; }
.video-event-detail__dot.tone-other    { background: #64748b; }
.video-event-detail__time {
  margin-left: auto;
  font-size: 0.8rem;
  opacity: 0.8;
}
.video-event-detail__sub {
  opacity: 0.6;
  margin-left: 0.25rem;
}
.video-event-detail__close {
  border: 0;
  background: transparent;
  cursor: pointer;
  padding: 0 0.25rem;
  opacity: 0.6;
}
.video-event-detail__close:hover { opacity: 1; }
.video-event-detail__grid {
  display: grid;
  grid-template-columns: max-content 1fr;
  column-gap: 0.75rem;
  row-gap: 0.25rem;
  margin: 0.5rem 0 0;
  font-size: 0.82rem;
}
.video-event-detail__grid dt {
  opacity: 0.65;
  font-weight: 600;
}
.video-event-detail__grid dd {
  margin: 0;
  word-break: break-word;
}
.video-loading {
  text-align: center;
  padding: 2rem 1rem;
}
.video-loading-text {
  margin-top: 1rem;
  font-size: 0.95rem;
}
.video-loading-sub {
  margin-top: 0.25rem;
  font-size: 0.8rem;
  opacity: 0.6;
}
.video-spinner {
  width: 56px;
  height: 56px;
  margin: 0 auto;
  border: 4px solid rgba(127, 127, 127, 0.25);
  border-top-color: var(--bulma-primary, #485fc7);
  border-radius: 50%;
  animation: video-spin 0.9s linear infinite;
}
@keyframes video-spin {
  to { transform: rotate(360deg); }
}
.video-error {
  text-align: center;
  color: var(--bulma-danger, #f14668);
  padding: 1.5rem 1rem;
}
.video-event-detail__image {
  float: right;
  margin: 0 0 0.5rem 0.75rem;
  width: 96px;
  height: 96px;
  border-radius: 6px;
  overflow: hidden;
  background: rgba(0, 0, 0, 0.04);
  display: flex;
  align-items: center;
  justify-content: center;
  border: 1px solid rgba(99, 102, 241, 0.18);
}
.video-event-detail__image img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
.video-event-detail__image-skeleton {
  font-size: 1.2rem;
  opacity: 0.5;
}
</style>
