<!-- Карточка вебкам-модели: ПОВЕДЕНИЕ без знания о провайдере.

Данные приходят уже нормализованными (`IWebcamModel`), маппинг живёт в
обёртках-адаптерах (`stripcash-card.vue`), поэтому карточку можно переиспользовать
для любого фида.

Поведение взято из purple-haze: клик по любой части тумбы ведёт по партнёрской
ссылке, флаги языков и страна — ссылки-фильтры на раздел вебкамов, теги — ссылки
на тег. Из нового: цель стримера и превью стрима появляются по ховеру, а на
тач-устройствах превью включается удержанием тумбы, а уход из кадра его гасит. -->
<template>
  <article
    class="card-webcam"
    @mouseenter="onEnter"
    @mouseleave="onLeave"
    @touchstart.passive="onTouchStart"
    @touchmove.passive="onTouchMove"
    @touchend="onTouchEnd"
    @touchcancel="onTouchEnd"
  >
    <a
      ref="posterRef"
      :href="model.url"
      class="card-webcam__poster"
      target="_blank"
      rel="nofollow noopener sponsored"
      @click="onClick"
    >
      <UiImg
        :src="poster"
        :alt="model.name"
        class="card-webcam__img"
        width="335"
        height="188"
        sizes="335px"
        loading="lazy"
        @error="onPosterError"
      />

      <video
        v-if="isPreviewActive && model.previewUrl"
        ref="videoRef"
        :class="{'--ready': isPreviewReady}"
        class="card-webcam__video"
        muted
        autoplay
        loop
        playsinline
        disablepictureinpicture
        @loadeddata="isPreviewReady = true"
      />

      <span
        v-if="model.isNew || model.viewers"
        class="card-webcam__badge"
      >
        <span
          v-if="model.isNew"
          class="card-webcam__badge-new"
        >{{ t('webcams.new_model') }}</span>

        <span
          v-if="model.viewers"
          class="card-webcam__badge-viewers"
        >
          <UiIcon
            name="eye"
            size="12"
          />{{ $n(model.viewers) }}
        </span>
      </span>

      <span class="card-webcam__online"/>

      <span
        v-if="model.goal"
        :class="{'--visible': isPreviewActive}"
        class="card-webcam__goal"
      >
        <span class="card-webcam__goal-text _truncate">
          <span class="card-webcam__goal-label">{{ t('webcams.goal') }}</span>{{ model.goal.message }}
        </span>
        <span class="card-webcam__goal-progress">
          <span class="card-webcam__goal-track">
            <span
              :style="{ width: `${model.goal.percent}%` }"
              class="card-webcam__goal-fill"
            />
          </span>
          <span class="card-webcam__goal-percent">{{ model.goal.percent }}%</span>
        </span>
      </span>
    </a>

    <div class="card-webcam__head">
      <CardWebcamLanguages
        :languages="model.languages"
        :flags="model.flags"
        :first-lang="firstLang"
        :niche-path="nichePath"
      />

      <a
        :href="model.url"
        class="card-webcam__name _truncate"
        target="_blank"
        rel="nofollow noopener sponsored"
        @click="onClick"
      >{{ model.name }}</a>

      <span
        v-if="model.age"
        class="card-webcam__age"
      >{{ t('webcams.years_old', { age: model.age }) }}</span>
    </div>

    <!-- На узких экранах ховера нет, поэтому цель стримера с прогрессом живёт под
    карточкой; на десктопе она остаётся оверлеем на постере. -->
    <div
      v-if="model.goal"
      class="card-webcam__goal-plain"
    >
      <span class="card-webcam__goal-plain-text _truncate">{{ model.goal.message }}</span>

      <span class="card-webcam__goal-progress">
        <span class="card-webcam__goal-track">
          <span
            :style="{ width: `${model.goal.percent}%` }"
            class="card-webcam__goal-fill"
          />
        </span>
        <span class="card-webcam__goal-percent --plain">{{ model.goal.percent }}%</span>
      </span>
    </div>

    <div
      v-if="model.viewers || model.country"
      class="card-webcam__meta"
    >
      <span
        v-if="model.viewers"
        class="card-webcam__viewers"
      >
        <UiIcon
          name="eye"
          size="14"
        />{{ $n(model.viewers) }}
      </span>

      <a
        v-if="model.country"
        :href="`${nichePath}/webcams?filter_country=${model.country}`"
        class="card-webcam__country _truncate"
      >{{ countryName }}</a>
    </div>

    <ul
      v-if="visibleTags.length"
      class="card-webcam__tags"
    >
      <li
        v-for="tag in visibleTags"
        :key="`tag-${tag}`"
      >
        <a
          :href="`${nichePath}/webcams/${tag}`"
          class="card-webcam__tag"
        >{{ tagTitle(tag) }}</a>
      </li>
    </ul>
  </article>
</template>

<script setup lang="ts">
import type { IWebcamModel } from '../../../types';

const props = withDefaults(defineProps<{
  model: IWebcamModel
  /** Тач-устройство: превью запускается удержанием тумбы, а не наведением. */
  touchPreview?: boolean
  /** Язык пользователя: его флаг у модели показываем первым (логика purple-haze). */
  firstLang?: string
}>(), {
  touchPreview: false,
  firstLang: '',
});

/**
 * Статистику отдаём наружу событиями: индексы тумб и контекст страницы знает
 * блок, а не карточка. `poster` уезжает вместе с элементом — показ засчитывается
 * по его нижней границе (правило боевого движка).
 */
const emit = defineEmits<{
  click: []
  poster: [element: HTMLElement]
}>();

const { t, locale } = useI18n();

const posterRef = ref<HTMLElement | null>(null);
const videoRef = ref<HTMLVideoElement | null>(null);

/**
 * Снапшот провайдера живёт по временной ссылке и иногда не отдаётся — тогда
 * переключаемся на превью-тумбу, она лежит на постоянном адресе.
 */
const isPosterBroken = ref(false);
const poster = computed(() => (isPosterBroken.value ? props.model.posterFallback : props.model.poster));

function onPosterError() {
  if (!isPosterBroken.value && props.model.posterFallback) isPosterBroken.value = true;
}

/**
 * Превью играет только на одной тумбе: слот один на страницу. Наведение мышью
 * забирает его силой, автозапуск в кадре — только если слот свободен.
 */
const previewSlot = useWebcamPreviewSlot();
const previewId = computed(() => props.model.id || props.model.name);
const isPreviewActive = previewSlot.isActive(previewId.value);
// поток может и не поехать (сеть, недоступный CDN) — до первого кадра держим
// видео прозрачным, чтобы поверх постера не висел чёрный прямоугольник
const isPreviewReady = ref(false);

/** Ниша в пути: у straight префикса нет, у остальных — `/gay`, `/shemale`. */
const nichePath = computed(() => {
  const niche = NicheHelper.getSiteDefaultNiche();

  return niche && niche !== 'straight' ? `/${niche}` : '';
});

const countryName = computed(() => {
  const code = String(props.model.country || '');
  if (code.length !== 2) return code;

  // короткая форма: в карточке «США», а не «Соединенные Штаты» — как в макете
  return new Intl.DisplayNames([locale.value], { type: 'region', style: 'short' }).of(code.toUpperCase()) || code;
});

/**
 * Провайдер отдаёт десятки тегов (у иных больше сотни) — в карточке оставляем
 * первые четыре, как в макете: остальное всё равно не влезает в строку.
 */
const TAGS_LIMIT = 4;

const visibleTags = computed(() => (props.model.tags || []).slice(0, TAGS_LIMIT));

/** Теги провайдера приходят слагами (`cheap-privates`) — показываем словами. */
function tagTitle(tag: string): string {
  return tag.replace(/[-_]+/g, ' ').replace(/(^|\s)\S/g, char => char.toUpperCase());
}

/**
 * Превью — живой HLS-поток модели (`.m3u8`), а не файл: нативно его понимает
 * только Safari, остальным нужен hls.js. Библиотека грузится динамически и
 * только при первом превью — она весит больше, чем весь блок, и на большинстве
 * показов не нужна вовсе. Поток отпускаем сразу после ухода курсора: тумб на
 * странице много, и держать открытыми несколько трансляций незачем.
 *
 * Воспроизведение запускаем руками, как это делал боевой движок: атрибута
 * `autoplay` для потока, подключённого через MSE, недостаточно.
 */
let hls: { destroy: () => void } | null = null;

async function startPreview() {
  isPreviewReady.value = false;

  const source = props.model.previewUrl;
  if (!source) return;

  await nextTick();

  const video = videoRef.value;
  if (!video || !isPreviewActive.value) return;

  const { default: Hls } = await import('hls.js');
  if (!isPreviewActive.value) return;

  // порядок важен: Chrome на `canPlayType('…mpegurl')` отвечает «maybe», но HLS
  // нативно не играет, поэтому сначала пробуем hls.js и лишь потом нативный путь
  if (!Hls.isSupported()) {
    if (video.canPlayType('application/vnd.apple.mpegurl')) {
      video.src = source;
      play(video);
    }

    return;
  }

  const instance = new Hls({ maxBufferLength: 4, capLevelToPlayerSize: true });
  instance.loadSource(source);
  instance.attachMedia(video);
  instance.on(Hls.Events.MANIFEST_PARSED, () => play(video));
  hls = instance;
}

/** Уход со страницы или курсора в момент старта — обычное дело, ошибку глушим. */
function play(video: HTMLVideoElement) {
  video.play().catch(() => undefined);
}

function stopPreview() {
  isPreviewReady.value = false;
  hls?.destroy();
  hls = null;
}

function onEnter() {
  if (props.touchPreview) return;

  previewSlot.claim(previewId.value);
}

function onLeave() {
  if (props.touchPreview) return;

  previewSlot.release(previewId.value);
}

/**
 * Тач: превью включает удержание тумбы — короткий тап должен открывать модель, а
 * не запускать поток. Движение пальцем считаем скроллом и удержание отменяем.
 * После отпускания превью продолжает играть, пока тумба в кадре или пока слот не
 * забрала другая; уход из кадра его останавливает.
 */
const HOLD_DELAY = 350;
const SCROLL_THRESHOLD = 10;

let holdTimer = 0;
let touchStartY = 0;
let wasHold = false;

function cancelHold() {
  clearTimeout(holdTimer);
  holdTimer = 0;
}

function onTouchStart(event: TouchEvent) {
  if (!props.touchPreview) return;

  wasHold = false;
  touchStartY = event.touches[0]?.clientY || 0;
  holdTimer = window.setTimeout(() => {
    wasHold = true;
    previewSlot.claim(previewId.value);
  }, HOLD_DELAY);
}

function onTouchMove(event: TouchEvent) {
  if (!holdTimer) return;

  const shift = Math.abs((event.touches[0]?.clientY || 0) - touchStartY);
  if (shift > SCROLL_THRESHOLD) cancelHold();
}

function onTouchEnd() {
  cancelHold();
}

function onClick(event: MouseEvent) {
  // удержание запускало превью — переход по ссылке в этом случае не нужен
  if (wasHold) {
    wasHold = false;
    event.preventDefault();

    return;
  }

  emit('click');
}

watch(isPreviewActive, (active) => {
  if (active) startPreview();
  else stopPreview();
});

let observer: IntersectionObserver | null = null;

onMounted(() => {
  if (posterRef.value) emit('poster', posterRef.value);

  if (!props.touchPreview || !posterRef.value) return;

  // проскроллили мимо — поток отпускаем, чтобы не тянуть его вслепую
  observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (!entry.isIntersecting) previewSlot.release(previewId.value);
    });
  }, { rootMargin: '50px' });

  observer.observe(posterRef.value);
});

onBeforeUnmount(() => {
  observer?.disconnect();
  observer = null;
  previewSlot.release(previewId.value);
  stopPreview();
});
</script>

<style lang="scss">
.card-webcam {
  min-width: 0;
}

// нормализация даёт каждому следующему ребёнку `<article>` отступ 1em — в карточке
// отступы задаются построчно, поэтому ресет тут гасим
.card-webcam > * + * {
  margin-top: 0;
}

.card-webcam__poster {
  position: relative;
  display: block;
  overflow: hidden;
  aspect-ratio: 16 / 9;
  margin-bottom: 12px;
  background-color: $card-webcam-poster-bg;
  border-radius: $radius-lg;

  @include from-br(xm) {
    margin-bottom: 16px;
  }
}

.card-webcam__img,
.card-webcam__video {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.card-webcam__video {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity .2s;
}

.card-webcam__video.--ready {
  opacity: 1;
}

// одна плашка на постере: признак новой модели и зрители онлайн (на узких экранах
// меты под карточкой нет, поэтому зрители показываются здесь)
.card-webcam__badge {
  @include font-4xs;

  position: absolute;
  top: 8px;
  left: 8px;
  z-index: 2;
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 2px 6px;
  font-weight: 600;
  line-height: 1;
  text-transform: uppercase;
  letter-spacing: .025em;
  color: $card-webcam-badge-text;
  text-shadow: 0 1px 2px $card-webcam-badge-shadow;

  // лёгкое затенение: на светлом постере плашка иначе сливается с кадром
  background-color: $card-webcam-badge-bg;
  border: 1px solid $card-webcam-badge-border;
  border-radius: $radius-small;
}

.card-webcam__badge-viewers {
  display: flex;
  align-items: center;
  gap: 4px;

  @include from-br(xm) {
    display: none;
  }
}

// на десктопе плашка нужна только под признак новой модели
.card-webcam__badge:not(:has(.card-webcam__badge-new)) {
  @include from-br(xm) {
    display: none;
  }
}

.card-webcam__online {
  position: absolute;
  top: 8px;
  right: 8px;
  z-index: 2;
  width: 10px;
  height: 10px;
  background-color: $card-webcam-online-dot;
  border-radius: 50%;
  box-shadow: 0 0 0 2px $card-webcam-online-ring;
}

.card-webcam__goal {
  position: absolute;
  inset-inline: 0;
  bottom: 0;
  z-index: 2;
  display: grid;
  row-gap: 6px;
  padding: 40px 12px 8px;
  background: linear-gradient(to top, $card-webcam-goal-shade-from, $card-webcam-goal-shade-via 45%, transparent);
  opacity: 0;
  transition: opacity .2s, translate .2s;
  translate: 0 4px;

  &.--visible {
    opacity: 1;
    translate: 0 0;
  }
}

.card-webcam__goal-text {
  @include font-sm;

  --line-clamp: 1;

  color: $card-webcam-goal-text;
  text-shadow: 0 1px 3px $card-webcam-goal-shade-from;
}

.card-webcam__goal-label {
  margin-inline-end: 4px;
  color: $card-webcam-goal-label;
}

.card-webcam__goal-progress {
  display: flex;
  align-items: center;
  gap: 8px;
}

.card-webcam__goal-track {
  position: relative;
  overflow: hidden;
  flex: 1;
  height: 4px;
  background-color: $card-webcam-goal-track-bg;
  border-radius: $radius-small;
}

.card-webcam__goal-fill {
  display: block;
  height: 100%;
  background-color: $card-webcam-goal-fill-bg;
  border-radius: $radius-small;
  transition: width .5s;
}

.card-webcam__goal-percent {
  @include font-3xs;

  flex-shrink: 0;
  min-width: 4ch;
  line-height: 16px;
  font-weight: 500;
  font-variant-numeric: tabular-nums;
  text-align: right;
  color: $card-webcam-goal-percent;

  &.--plain {
    color: $card-webcam-goal-plain-percent;
  }
}

// цель под карточкой — только там, где нет ховера
.card-webcam__goal-plain {
  display: grid;
  row-gap: 6px;
  margin-top: 8px;

  @include from-br(xm) {
    display: none;
  }
}

.card-webcam__goal-plain-text {
  @include font-xs;

  --line-clamp: 1;

  color: $card-webcam-goal-plain-text;
}

.card-webcam__head {
  display: flex;
  align-items: center;
  gap: 8px;
  min-width: 0;
}

.card-webcam__name {
  @include font-sm;

  --line-clamp: 1;

  font-weight: 600;
  color: $card-webcam-name-text;

  &:is(:hover, :focus) {
    color: $card-webcam-name-text--hover;
  }
}

.card-webcam__age {
  @include font-sm;

  flex-shrink: 0;
  color: $card-webcam-meta-text;
}

// на узких экранах мету не показываем: зрители уехали в плашку на постере, а
// место под карточкой занимает цель стримера
.card-webcam__meta {
  @include font-xs;

  display: none;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin-top: 8px;
  color: $card-webcam-meta-text;

  @include from-br(xm) {
    display: flex;
  }
}

.card-webcam__viewers {
  display: flex;
  align-items: center;
  gap: 4px;
}

.card-webcam__country {
  --line-clamp: 1;

  margin-left: auto;

  &:is(:hover, :focus) {
    color: $card-webcam-meta-text--hover;
  }
}

// селектор с родителем не случайно: ресет обнуляет отступы у `ul[class]`, и
// одиночного класса ему не хватает
.card-webcam > .card-webcam__tags {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 10px;
}

// на узких экранах теги в один ряд с горизонтальным скроллом: в две колонки они
// не влезают, а перенос делал карточки в сетке разной высоты
.card-webcam > .card-webcam__tags {
  @include to-br(xm) {
    flex-wrap: nowrap;
    overflow-x: auto;
    padding-right: 24px;
    scrollbar-width: none;

    &::-webkit-scrollbar {
      display: none;
    }
  }
}

// не сжимаем элементы ряда: иначе подпись внутри чипса переносится и ряд
// становится двухстрочным
.card-webcam__tags > li {
  flex-shrink: 0;
}

.card-webcam__tag {
  @include font-xs;

  display: block;
  white-space: nowrap;
  padding: 4px 8px;
  color: $card-webcam-tag-text;
  background-color: $card-webcam-tag-bg;
  border-radius: $radius-md;

  &:is(:hover, :focus) {
    color: $card-webcam-tag-text--hover;
    background-color: $card-webcam-tag-bg--hover;
  }

  @include to-br(xm) {
    @include font-3xs;

    padding: 2px 8px;
    line-height: 18px;
  }
}
</style>
