itube-specs 0.0.918 → 0.0.919
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/scss/themes/dark/_theme.scss +36 -0
- package/assets/scss/themes/light/_theme.scss +36 -0
- package/assets/scss/vars/_palette.scss +4 -0
- package/components/card/webcam/index.vue +577 -0
- package/components/card/webcam/languages.vue +118 -0
- package/components/player/core.vue +15 -1
- package/components/webcam/counters.vue +113 -0
- package/components/webcam/header.vue +170 -0
- package/components/webcam/main.vue +155 -0
- package/components/webcam/stripcash-card.vue +77 -0
- package/composables/fetch/use-fetch-webcams.ts +51 -0
- package/composables/use-webcam-preview-slot.ts +44 -0
- package/nuxt.config.ts +1 -0
- package/package.json +3 -2
- package/runtime/constants/webcams.ts +17 -0
- package/runtime/index.ts +1 -0
- package/server/api/webcams/block.get.ts +49 -0
- package/types/index.d.ts +4 -0
- package/types/stripcash-model.d.ts +55 -0
- package/types/webcam-counts.d.ts +29 -0
- package/types/webcam-model.d.ts +36 -0
- package/types/webcams-config.d.ts +14 -0
- package/utils/webcams/__tests__/adapters.test.ts +22 -0
- package/utils/webcams/__tests__/build-model-link.test.ts +29 -0
- package/utils/webcams/__tests__/goal-percent.test.ts +16 -0
- package/utils/webcams/__tests__/model-tags.test.ts +22 -0
- package/utils/webcams/__tests__/resolve-stream-preview.test.ts +35 -0
- package/utils/webcams/adapters.ts +30 -0
- package/utils/webcams/build-model-link.ts +22 -0
- package/utils/webcams/goal-percent.ts +6 -0
- package/utils/webcams/lang-to-flag.ts +73 -0
- package/utils/webcams/model-tags.ts +24 -0
- package/utils/webcams/resolve-stream-preview.ts +30 -0
|
@@ -1057,3 +1057,39 @@ $document-text: $color-zinc-400;
|
|
|
1057
1057
|
$document-heading: $color-white;
|
|
1058
1058
|
$document-link: $color-indigo-400;
|
|
1059
1059
|
$document-link--hover: $color-indigo-300;
|
|
1060
|
+
|
|
1061
|
+
// ─── Вебкам-блок ───
|
|
1062
|
+
$webcam-header-icon: $color-white;
|
|
1063
|
+
$webcam-header-icon-bg: $color-white-10;
|
|
1064
|
+
$webcam-header-promo-text: $color-white;
|
|
1065
|
+
$webcam-header-promo-bg: $color-indigo-600;
|
|
1066
|
+
$webcam-header-promo-bg--hover: $color-indigo-500;
|
|
1067
|
+
$webcam-counters-text: $color-emerald-400;
|
|
1068
|
+
$webcam-counters-bg: $color-emerald-500-10;
|
|
1069
|
+
$webcam-counters-dot: $color-emerald-400;
|
|
1070
|
+
$webcam-counters-language-text: $color-zinc-300;
|
|
1071
|
+
$webcam-counters-language-bg: $color-zinc-800-70;
|
|
1072
|
+
$card-webcam-poster-bg: $color-zinc-800;
|
|
1073
|
+
$card-webcam-online-dot: $color-emerald-400;
|
|
1074
|
+
$card-webcam-online-ring: $color-black-42;
|
|
1075
|
+
$card-webcam-new-text: $color-white-90;
|
|
1076
|
+
$card-webcam-new-bg: $color-black-42;
|
|
1077
|
+
$card-webcam-new-border: $color-white-25;
|
|
1078
|
+
$card-webcam-goal-text: $color-white;
|
|
1079
|
+
$card-webcam-goal-label: $color-zinc-400;
|
|
1080
|
+
$card-webcam-goal-percent: $color-white-70;
|
|
1081
|
+
$card-webcam-goal-track-bg: $color-white-15;
|
|
1082
|
+
$card-webcam-goal-fill-bg: $color-emerald-400;
|
|
1083
|
+
$card-webcam-goal-shade-from: $color-black-95;
|
|
1084
|
+
$card-webcam-goal-shade-via: $color-black-70;
|
|
1085
|
+
$card-webcam-name-text: $color-white;
|
|
1086
|
+
$card-webcam-name-text--hover: $color-indigo-300;
|
|
1087
|
+
$card-webcam-meta-text: $color-zinc-500;
|
|
1088
|
+
$card-webcam-meta-text--hover: $color-zinc-300;
|
|
1089
|
+
$card-webcam-tag-text: $color-zinc-300;
|
|
1090
|
+
$card-webcam-tag-bg: $color-zinc-800;
|
|
1091
|
+
$card-webcam-tag-text--hover: $color-white;
|
|
1092
|
+
$card-webcam-tag-bg--hover: $color-zinc-700;
|
|
1093
|
+
$card-webcam-languages-border: $color-white-20;
|
|
1094
|
+
$card-webcam-languages-fallback-bg: $color-zinc-800;
|
|
1095
|
+
$card-webcam-languages-fallback-icon: $color-zinc-300;
|
|
@@ -1085,3 +1085,39 @@ $document-text: $color-gray-700;
|
|
|
1085
1085
|
$document-heading: $color-gray-900;
|
|
1086
1086
|
$document-link: $color-indigo-400;
|
|
1087
1087
|
$document-link--hover: $color-indigo-300;
|
|
1088
|
+
|
|
1089
|
+
// ─── Вебкам-блок ───
|
|
1090
|
+
$webcam-header-icon: $color-gray-700;
|
|
1091
|
+
$webcam-header-icon-bg: $color-gray-200;
|
|
1092
|
+
$webcam-header-promo-text: $color-white;
|
|
1093
|
+
$webcam-header-promo-bg: $color-indigo-600;
|
|
1094
|
+
$webcam-header-promo-bg--hover: $color-indigo-500;
|
|
1095
|
+
$webcam-counters-text: $color-emerald-600;
|
|
1096
|
+
$webcam-counters-bg: $color-emerald-500-10;
|
|
1097
|
+
$webcam-counters-dot: $color-emerald-500;
|
|
1098
|
+
$webcam-counters-language-text: $color-gray-700;
|
|
1099
|
+
$webcam-counters-language-bg: $color-gray-200;
|
|
1100
|
+
$card-webcam-poster-bg: $color-gray-200;
|
|
1101
|
+
$card-webcam-online-dot: $color-emerald-500;
|
|
1102
|
+
$card-webcam-online-ring: $color-black-25;
|
|
1103
|
+
$card-webcam-new-text: $color-white;
|
|
1104
|
+
$card-webcam-new-bg: $color-black-42;
|
|
1105
|
+
$card-webcam-new-border: $color-white-25;
|
|
1106
|
+
$card-webcam-goal-text: $color-white;
|
|
1107
|
+
$card-webcam-goal-label: $color-gray-300;
|
|
1108
|
+
$card-webcam-goal-percent: $color-white-70;
|
|
1109
|
+
$card-webcam-goal-track-bg: $color-white-15;
|
|
1110
|
+
$card-webcam-goal-fill-bg: $color-emerald-400;
|
|
1111
|
+
$card-webcam-goal-shade-from: $color-black-95;
|
|
1112
|
+
$card-webcam-goal-shade-via: $color-black-70;
|
|
1113
|
+
$card-webcam-name-text: $color-gray-950;
|
|
1114
|
+
$card-webcam-name-text--hover: $color-indigo-600;
|
|
1115
|
+
$card-webcam-meta-text: $color-gray-500;
|
|
1116
|
+
$card-webcam-meta-text--hover: $color-gray-700;
|
|
1117
|
+
$card-webcam-tag-text: $color-gray-700;
|
|
1118
|
+
$card-webcam-tag-bg: $color-gray-200;
|
|
1119
|
+
$card-webcam-tag-text--hover: $color-gray-950;
|
|
1120
|
+
$card-webcam-tag-bg--hover: $color-gray-300;
|
|
1121
|
+
$card-webcam-languages-border: $color-gray-300;
|
|
1122
|
+
$card-webcam-languages-fallback-bg: $color-gray-200;
|
|
1123
|
+
$card-webcam-languages-fallback-icon: $color-gray-600;
|
|
@@ -113,11 +113,13 @@ $color-black-10: lab(0% 0 0 / 10%);
|
|
|
113
113
|
$color-black-5: lab(0% 0 0 / 5%);
|
|
114
114
|
$color-white-100: lab(100% 0 0);
|
|
115
115
|
$color-white-90: lab(100% 0 0 / 90%);
|
|
116
|
+
$color-white-70: lab(100% 0 0 / 70%);
|
|
116
117
|
$color-white-75: lab(100% 0 0 / 75%);
|
|
117
118
|
$color-white-60: lab(100% 0 0 / 60%);
|
|
118
119
|
$color-white-50: lab(100% 0 0 / 50%);
|
|
119
120
|
$color-white-45: lab(100% 0 0 / 45%);
|
|
120
121
|
$color-white-25: lab(100% 0 0 / 25%);
|
|
122
|
+
$color-white-15: lab(100% 0 0 / 15%);
|
|
121
123
|
$color-white-20: lab(100% 0 0 / 20%);
|
|
122
124
|
$color-white-10: lab(100% 0 0 / 10%);
|
|
123
125
|
$color-white-5: lab(100% 0 0 / 5%);
|
|
@@ -138,6 +140,8 @@ $color-red-900-50: color-mix(in oklab, #{$color-red-900} 50%, transparent);
|
|
|
138
140
|
$color-red-925: color-mix(in oklab, #{$color-red-900} 90%, #{$color-black});
|
|
139
141
|
$color-red-950: lab(13.003% 29.04 16.7519);
|
|
140
142
|
$color-emerald-400: lab(75.0771% -60.7313 19.4147);
|
|
143
|
+
$color-emerald-500-10: lab(66.9756% -58.27 19.5419 / 10%);
|
|
144
|
+
$color-emerald-500-20: lab(66.9756% -58.27 19.5419 / 20%);
|
|
141
145
|
$color-emerald-500: lab(66.9756% -58.27 19.5419);
|
|
142
146
|
$color-emerald-600: lab(55.0481% -49.9246 15.93);
|
|
143
147
|
$color-amber-400: lab(80.1641% 16.6016 99.2089);
|
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
<!-- Карточка вебкам-модели: ПОВЕДЕНИЕ без знания о провайдере.
|
|
2
|
+
|
|
3
|
+
Данные приходят уже нормализованными (`IWebcamModel`), маппинг живёт в
|
|
4
|
+
обёртках-адаптерах (`stripcash-card.vue`), поэтому карточку можно переиспользовать
|
|
5
|
+
для любого фида.
|
|
6
|
+
|
|
7
|
+
Поведение взято из purple-haze: клик по любой части тумбы ведёт по партнёрской
|
|
8
|
+
ссылке, флаги языков и страна — ссылки-фильтры на раздел вебкамов, теги — ссылки
|
|
9
|
+
на тег. Из нового: цель стримера и превью стрима появляются по ховеру, а на
|
|
10
|
+
тач-устройствах превью включается удержанием тумбы, а уход из кадра его гасит. -->
|
|
11
|
+
<template>
|
|
12
|
+
<article
|
|
13
|
+
class="card-webcam"
|
|
14
|
+
@mouseenter="onEnter"
|
|
15
|
+
@mouseleave="onLeave"
|
|
16
|
+
@touchstart.passive="onTouchStart"
|
|
17
|
+
@touchmove.passive="onTouchMove"
|
|
18
|
+
@touchend="onTouchEnd"
|
|
19
|
+
@touchcancel="onTouchEnd"
|
|
20
|
+
>
|
|
21
|
+
<a
|
|
22
|
+
ref="posterRef"
|
|
23
|
+
:href="model.url"
|
|
24
|
+
class="card-webcam__poster"
|
|
25
|
+
target="_blank"
|
|
26
|
+
rel="nofollow noopener sponsored"
|
|
27
|
+
@click="onClick"
|
|
28
|
+
>
|
|
29
|
+
<UiImg
|
|
30
|
+
:src="poster"
|
|
31
|
+
:alt="model.name"
|
|
32
|
+
class="card-webcam__img"
|
|
33
|
+
width="335"
|
|
34
|
+
height="188"
|
|
35
|
+
sizes="335px"
|
|
36
|
+
loading="lazy"
|
|
37
|
+
@error="onPosterError"
|
|
38
|
+
/>
|
|
39
|
+
|
|
40
|
+
<video
|
|
41
|
+
v-if="isPreviewActive && model.previewUrl"
|
|
42
|
+
ref="videoRef"
|
|
43
|
+
:class="{'--ready': isPreviewReady}"
|
|
44
|
+
class="card-webcam__video"
|
|
45
|
+
muted
|
|
46
|
+
autoplay
|
|
47
|
+
loop
|
|
48
|
+
playsinline
|
|
49
|
+
disablepictureinpicture
|
|
50
|
+
@loadeddata="isPreviewReady = true"
|
|
51
|
+
/>
|
|
52
|
+
|
|
53
|
+
<span
|
|
54
|
+
v-if="model.isNew"
|
|
55
|
+
class="card-webcam__new"
|
|
56
|
+
>{{ t('webcams.new_model') }}</span>
|
|
57
|
+
|
|
58
|
+
<span class="card-webcam__online"/>
|
|
59
|
+
|
|
60
|
+
<span
|
|
61
|
+
v-if="model.goal"
|
|
62
|
+
:class="{'--visible': isPreviewActive}"
|
|
63
|
+
class="card-webcam__goal"
|
|
64
|
+
>
|
|
65
|
+
<span class="card-webcam__goal-text _truncate">
|
|
66
|
+
<span class="card-webcam__goal-label">{{ t('webcams.goal') }}</span>{{ model.goal.message }}
|
|
67
|
+
</span>
|
|
68
|
+
<span class="card-webcam__goal-progress">
|
|
69
|
+
<span class="card-webcam__goal-track">
|
|
70
|
+
<span
|
|
71
|
+
:style="{ width: `${model.goal.percent}%` }"
|
|
72
|
+
class="card-webcam__goal-fill"
|
|
73
|
+
/>
|
|
74
|
+
</span>
|
|
75
|
+
<span class="card-webcam__goal-percent">{{ model.goal.percent }}%</span>
|
|
76
|
+
</span>
|
|
77
|
+
</span>
|
|
78
|
+
</a>
|
|
79
|
+
|
|
80
|
+
<div class="card-webcam__head">
|
|
81
|
+
<CardWebcamLanguages
|
|
82
|
+
:languages="model.languages"
|
|
83
|
+
:flags="model.flags"
|
|
84
|
+
:first-lang="firstLang"
|
|
85
|
+
:niche-path="nichePath"
|
|
86
|
+
/>
|
|
87
|
+
|
|
88
|
+
<a
|
|
89
|
+
:href="model.url"
|
|
90
|
+
class="card-webcam__name _truncate"
|
|
91
|
+
target="_blank"
|
|
92
|
+
rel="nofollow noopener sponsored"
|
|
93
|
+
@click="onClick"
|
|
94
|
+
>{{ model.name }}</a>
|
|
95
|
+
|
|
96
|
+
<span
|
|
97
|
+
v-if="model.age"
|
|
98
|
+
class="card-webcam__age"
|
|
99
|
+
>{{ t('webcams.years_old', { age: model.age }) }}</span>
|
|
100
|
+
</div>
|
|
101
|
+
|
|
102
|
+
<div class="card-webcam__meta">
|
|
103
|
+
<span
|
|
104
|
+
v-if="model.viewers"
|
|
105
|
+
class="card-webcam__viewers"
|
|
106
|
+
>
|
|
107
|
+
<UiIcon
|
|
108
|
+
name="eye"
|
|
109
|
+
size="14"
|
|
110
|
+
/>{{ $n(model.viewers) }}
|
|
111
|
+
</span>
|
|
112
|
+
|
|
113
|
+
<a
|
|
114
|
+
v-if="model.country"
|
|
115
|
+
:href="`${nichePath}/webcams?mf_country=${model.country}`"
|
|
116
|
+
class="card-webcam__country _truncate"
|
|
117
|
+
>{{ countryName }}</a>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<ul
|
|
121
|
+
v-if="visibleTags.length"
|
|
122
|
+
class="card-webcam__tags"
|
|
123
|
+
>
|
|
124
|
+
<li
|
|
125
|
+
v-for="tag in visibleTags"
|
|
126
|
+
:key="`tag-${tag}`"
|
|
127
|
+
>
|
|
128
|
+
<a
|
|
129
|
+
:href="`${nichePath}/webcams/${tag}`"
|
|
130
|
+
class="card-webcam__tag"
|
|
131
|
+
>{{ tagTitle(tag) }}</a>
|
|
132
|
+
</li>
|
|
133
|
+
</ul>
|
|
134
|
+
</article>
|
|
135
|
+
</template>
|
|
136
|
+
|
|
137
|
+
<script setup lang="ts">
|
|
138
|
+
import type { IWebcamModel } from '../../../types';
|
|
139
|
+
|
|
140
|
+
const props = withDefaults(defineProps<{
|
|
141
|
+
model: IWebcamModel
|
|
142
|
+
/** Тач-устройство: превью запускается удержанием тумбы, а не наведением. */
|
|
143
|
+
touchPreview?: boolean
|
|
144
|
+
/** Язык пользователя: его флаг у модели показываем первым (логика purple-haze). */
|
|
145
|
+
firstLang?: string
|
|
146
|
+
}>(), {
|
|
147
|
+
touchPreview: false,
|
|
148
|
+
firstLang: '',
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Статистику отдаём наружу событиями: индексы тумб и контекст страницы знает
|
|
153
|
+
* блок, а не карточка. `poster` уезжает вместе с элементом — показ засчитывается
|
|
154
|
+
* по его нижней границе (правило боевого движка).
|
|
155
|
+
*/
|
|
156
|
+
const emit = defineEmits<{
|
|
157
|
+
click: []
|
|
158
|
+
poster: [element: HTMLElement]
|
|
159
|
+
}>();
|
|
160
|
+
|
|
161
|
+
const { t, locale } = useI18n();
|
|
162
|
+
|
|
163
|
+
const posterRef = ref<HTMLElement | null>(null);
|
|
164
|
+
const videoRef = ref<HTMLVideoElement | null>(null);
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Снапшот провайдера живёт по временной ссылке и иногда не отдаётся — тогда
|
|
168
|
+
* переключаемся на превью-тумбу, она лежит на постоянном адресе.
|
|
169
|
+
*/
|
|
170
|
+
const isPosterBroken = ref(false);
|
|
171
|
+
const poster = computed(() => (isPosterBroken.value ? props.model.posterFallback : props.model.poster));
|
|
172
|
+
|
|
173
|
+
function onPosterError() {
|
|
174
|
+
if (!isPosterBroken.value && props.model.posterFallback) isPosterBroken.value = true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Превью играет только на одной тумбе: слот один на страницу. Наведение мышью
|
|
179
|
+
* забирает его силой, автозапуск в кадре — только если слот свободен.
|
|
180
|
+
*/
|
|
181
|
+
const previewSlot = useWebcamPreviewSlot();
|
|
182
|
+
const previewId = computed(() => props.model.id || props.model.name);
|
|
183
|
+
const isPreviewActive = previewSlot.isActive(previewId.value);
|
|
184
|
+
// поток может и не поехать (сеть, недоступный CDN) — до первого кадра держим
|
|
185
|
+
// видео прозрачным, чтобы поверх постера не висел чёрный прямоугольник
|
|
186
|
+
const isPreviewReady = ref(false);
|
|
187
|
+
|
|
188
|
+
/** Ниша в пути: у straight префикса нет, у остальных — `/gay`, `/shemale`. */
|
|
189
|
+
const nichePath = computed(() => {
|
|
190
|
+
const niche = NicheHelper.getSiteDefaultNiche();
|
|
191
|
+
|
|
192
|
+
return niche && niche !== 'straight' ? `/${niche}` : '';
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const countryName = computed(() => {
|
|
196
|
+
const code = String(props.model.country || '');
|
|
197
|
+
if (code.length !== 2) return code;
|
|
198
|
+
|
|
199
|
+
// короткая форма: в карточке «США», а не «Соединенные Штаты» — как в макете
|
|
200
|
+
return new Intl.DisplayNames([locale.value], { type: 'region', style: 'short' }).of(code.toUpperCase()) || code;
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Провайдер отдаёт десятки тегов (у иных больше сотни) — в карточке оставляем
|
|
205
|
+
* первые четыре, как в макете: остальное всё равно не влезает в строку.
|
|
206
|
+
*/
|
|
207
|
+
const TAGS_LIMIT = 4;
|
|
208
|
+
|
|
209
|
+
const visibleTags = computed(() => (props.model.tags || []).slice(0, TAGS_LIMIT));
|
|
210
|
+
|
|
211
|
+
/** Теги провайдера приходят слагами (`cheap-privates`) — показываем словами. */
|
|
212
|
+
function tagTitle(tag: string): string {
|
|
213
|
+
return tag.replace(/[-_]+/g, ' ').replace(/(^|\s)\S/g, char => char.toUpperCase());
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Превью — живой HLS-поток модели (`.m3u8`), а не файл: нативно его понимает
|
|
218
|
+
* только Safari, остальным нужен hls.js. Библиотека грузится динамически и
|
|
219
|
+
* только при первом превью — она весит больше, чем весь блок, и на большинстве
|
|
220
|
+
* показов не нужна вовсе. Поток отпускаем сразу после ухода курсора: тумб на
|
|
221
|
+
* странице много, и держать открытыми несколько трансляций незачем.
|
|
222
|
+
*
|
|
223
|
+
* Воспроизведение запускаем руками, как это делал боевой движок: атрибута
|
|
224
|
+
* `autoplay` для потока, подключённого через MSE, недостаточно.
|
|
225
|
+
*/
|
|
226
|
+
let hls: { destroy: () => void } | null = null;
|
|
227
|
+
|
|
228
|
+
async function startPreview() {
|
|
229
|
+
isPreviewReady.value = false;
|
|
230
|
+
|
|
231
|
+
const source = props.model.previewUrl;
|
|
232
|
+
if (!source) return;
|
|
233
|
+
|
|
234
|
+
await nextTick();
|
|
235
|
+
|
|
236
|
+
const video = videoRef.value;
|
|
237
|
+
if (!video || !isPreviewActive.value) return;
|
|
238
|
+
|
|
239
|
+
const { default: Hls } = await import('hls.js');
|
|
240
|
+
if (!isPreviewActive.value) return;
|
|
241
|
+
|
|
242
|
+
// порядок важен: Chrome на `canPlayType('…mpegurl')` отвечает «maybe», но HLS
|
|
243
|
+
// нативно не играет, поэтому сначала пробуем hls.js и лишь потом нативный путь
|
|
244
|
+
if (!Hls.isSupported()) {
|
|
245
|
+
if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
|
246
|
+
video.src = source;
|
|
247
|
+
play(video);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const instance = new Hls({ maxBufferLength: 4, capLevelToPlayerSize: true });
|
|
254
|
+
instance.loadSource(source);
|
|
255
|
+
instance.attachMedia(video);
|
|
256
|
+
instance.on(Hls.Events.MANIFEST_PARSED, () => play(video));
|
|
257
|
+
hls = instance;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Уход со страницы или курсора в момент старта — обычное дело, ошибку глушим. */
|
|
261
|
+
function play(video: HTMLVideoElement) {
|
|
262
|
+
video.play().catch(() => undefined);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function stopPreview() {
|
|
266
|
+
isPreviewReady.value = false;
|
|
267
|
+
hls?.destroy();
|
|
268
|
+
hls = null;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function onEnter() {
|
|
272
|
+
if (props.touchPreview) return;
|
|
273
|
+
|
|
274
|
+
previewSlot.claim(previewId.value);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function onLeave() {
|
|
278
|
+
if (props.touchPreview) return;
|
|
279
|
+
|
|
280
|
+
previewSlot.release(previewId.value);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Тач: превью включает удержание тумбы — короткий тап должен открывать модель, а
|
|
285
|
+
* не запускать поток. Движение пальцем считаем скроллом и удержание отменяем.
|
|
286
|
+
* После отпускания превью продолжает играть, пока тумба в кадре или пока слот не
|
|
287
|
+
* забрала другая; уход из кадра его останавливает.
|
|
288
|
+
*/
|
|
289
|
+
const HOLD_DELAY = 350;
|
|
290
|
+
const SCROLL_THRESHOLD = 10;
|
|
291
|
+
|
|
292
|
+
let holdTimer = 0;
|
|
293
|
+
let touchStartY = 0;
|
|
294
|
+
let wasHold = false;
|
|
295
|
+
|
|
296
|
+
function cancelHold() {
|
|
297
|
+
clearTimeout(holdTimer);
|
|
298
|
+
holdTimer = 0;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function onTouchStart(event: TouchEvent) {
|
|
302
|
+
if (!props.touchPreview) return;
|
|
303
|
+
|
|
304
|
+
wasHold = false;
|
|
305
|
+
touchStartY = event.touches[0]?.clientY || 0;
|
|
306
|
+
holdTimer = window.setTimeout(() => {
|
|
307
|
+
wasHold = true;
|
|
308
|
+
previewSlot.claim(previewId.value);
|
|
309
|
+
}, HOLD_DELAY);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function onTouchMove(event: TouchEvent) {
|
|
313
|
+
if (!holdTimer) return;
|
|
314
|
+
|
|
315
|
+
const shift = Math.abs((event.touches[0]?.clientY || 0) - touchStartY);
|
|
316
|
+
if (shift > SCROLL_THRESHOLD) cancelHold();
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function onTouchEnd() {
|
|
320
|
+
cancelHold();
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function onClick(event: MouseEvent) {
|
|
324
|
+
// удержание запускало превью — переход по ссылке в этом случае не нужен
|
|
325
|
+
if (wasHold) {
|
|
326
|
+
wasHold = false;
|
|
327
|
+
event.preventDefault();
|
|
328
|
+
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
emit('click');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
watch(isPreviewActive, (active) => {
|
|
336
|
+
if (active) startPreview();
|
|
337
|
+
else stopPreview();
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
let observer: IntersectionObserver | null = null;
|
|
341
|
+
|
|
342
|
+
onMounted(() => {
|
|
343
|
+
if (posterRef.value) emit('poster', posterRef.value);
|
|
344
|
+
|
|
345
|
+
if (!props.touchPreview || !posterRef.value) return;
|
|
346
|
+
|
|
347
|
+
// проскроллили мимо — поток отпускаем, чтобы не тянуть его вслепую
|
|
348
|
+
observer = new IntersectionObserver((entries) => {
|
|
349
|
+
entries.forEach(entry => {
|
|
350
|
+
if (!entry.isIntersecting) previewSlot.release(previewId.value);
|
|
351
|
+
});
|
|
352
|
+
}, { rootMargin: '50px' });
|
|
353
|
+
|
|
354
|
+
observer.observe(posterRef.value);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
onBeforeUnmount(() => {
|
|
358
|
+
observer?.disconnect();
|
|
359
|
+
observer = null;
|
|
360
|
+
previewSlot.release(previewId.value);
|
|
361
|
+
stopPreview();
|
|
362
|
+
});
|
|
363
|
+
</script>
|
|
364
|
+
|
|
365
|
+
<style lang="scss">
|
|
366
|
+
.card-webcam {
|
|
367
|
+
min-width: 0;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
.card-webcam__poster {
|
|
371
|
+
position: relative;
|
|
372
|
+
display: block;
|
|
373
|
+
overflow: hidden;
|
|
374
|
+
aspect-ratio: 16 / 9;
|
|
375
|
+
margin-bottom: 12px;
|
|
376
|
+
background-color: $card-webcam-poster-bg;
|
|
377
|
+
border-radius: $radius-lg;
|
|
378
|
+
|
|
379
|
+
@include from-br(xm) {
|
|
380
|
+
margin-bottom: 16px;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
.card-webcam__img,
|
|
385
|
+
.card-webcam__video {
|
|
386
|
+
width: 100%;
|
|
387
|
+
height: 100%;
|
|
388
|
+
object-fit: cover;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
.card-webcam__video {
|
|
392
|
+
position: absolute;
|
|
393
|
+
inset: 0;
|
|
394
|
+
opacity: 0;
|
|
395
|
+
transition: opacity .2s;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
.card-webcam__video.--ready {
|
|
399
|
+
opacity: 1;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
.card-webcam__new {
|
|
403
|
+
@include font-4xs;
|
|
404
|
+
|
|
405
|
+
position: absolute;
|
|
406
|
+
top: 8px;
|
|
407
|
+
left: 8px;
|
|
408
|
+
z-index: 2;
|
|
409
|
+
padding: 2px 6px;
|
|
410
|
+
font-weight: 600;
|
|
411
|
+
line-height: 1;
|
|
412
|
+
text-transform: uppercase;
|
|
413
|
+
letter-spacing: .025em;
|
|
414
|
+
color: $card-webcam-new-text;
|
|
415
|
+
|
|
416
|
+
// лёгкое затенение: на светлом постере плашка иначе сливается с кадром
|
|
417
|
+
background-color: $card-webcam-new-bg;
|
|
418
|
+
border: 1px solid $card-webcam-new-border;
|
|
419
|
+
border-radius: $radius-small;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
.card-webcam__online {
|
|
423
|
+
position: absolute;
|
|
424
|
+
top: 8px;
|
|
425
|
+
right: 8px;
|
|
426
|
+
z-index: 2;
|
|
427
|
+
width: 10px;
|
|
428
|
+
height: 10px;
|
|
429
|
+
background-color: $card-webcam-online-dot;
|
|
430
|
+
border-radius: 50%;
|
|
431
|
+
box-shadow: 0 0 0 2px $card-webcam-online-ring;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
.card-webcam__goal {
|
|
435
|
+
position: absolute;
|
|
436
|
+
inset-inline: 0;
|
|
437
|
+
bottom: 0;
|
|
438
|
+
z-index: 2;
|
|
439
|
+
display: grid;
|
|
440
|
+
row-gap: 6px;
|
|
441
|
+
padding: 40px 12px 8px;
|
|
442
|
+
background: linear-gradient(to top, $card-webcam-goal-shade-from, $card-webcam-goal-shade-via 45%, transparent);
|
|
443
|
+
opacity: 0;
|
|
444
|
+
transition: opacity .2s, translate .2s;
|
|
445
|
+
translate: 0 4px;
|
|
446
|
+
|
|
447
|
+
&.--visible {
|
|
448
|
+
opacity: 1;
|
|
449
|
+
translate: 0 0;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
.card-webcam__goal-text {
|
|
454
|
+
@include font-sm;
|
|
455
|
+
|
|
456
|
+
--line-clamp: 1;
|
|
457
|
+
|
|
458
|
+
color: $card-webcam-goal-text;
|
|
459
|
+
text-shadow: 0 1px 3px $card-webcam-goal-shade-from;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
.card-webcam__goal-label {
|
|
463
|
+
margin-inline-end: 4px;
|
|
464
|
+
color: $card-webcam-goal-label;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
.card-webcam__goal-progress {
|
|
468
|
+
display: flex;
|
|
469
|
+
align-items: center;
|
|
470
|
+
gap: 8px;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
.card-webcam__goal-track {
|
|
474
|
+
position: relative;
|
|
475
|
+
overflow: hidden;
|
|
476
|
+
flex: 1;
|
|
477
|
+
height: 4px;
|
|
478
|
+
background-color: $card-webcam-goal-track-bg;
|
|
479
|
+
border-radius: $radius-small;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
.card-webcam__goal-fill {
|
|
483
|
+
display: block;
|
|
484
|
+
height: 100%;
|
|
485
|
+
background-color: $card-webcam-goal-fill-bg;
|
|
486
|
+
border-radius: $radius-small;
|
|
487
|
+
transition: width .5s;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
.card-webcam__goal-percent {
|
|
491
|
+
@include font-3xs;
|
|
492
|
+
|
|
493
|
+
flex-shrink: 0;
|
|
494
|
+
min-width: 4ch;
|
|
495
|
+
font-weight: 500;
|
|
496
|
+
font-variant-numeric: tabular-nums;
|
|
497
|
+
text-align: right;
|
|
498
|
+
color: $card-webcam-goal-percent;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
.card-webcam__head {
|
|
502
|
+
display: flex;
|
|
503
|
+
align-items: center;
|
|
504
|
+
gap: 8px;
|
|
505
|
+
min-width: 0;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
.card-webcam__name {
|
|
509
|
+
@include font-sm;
|
|
510
|
+
|
|
511
|
+
--line-clamp: 1;
|
|
512
|
+
|
|
513
|
+
font-weight: 600;
|
|
514
|
+
color: $card-webcam-name-text;
|
|
515
|
+
|
|
516
|
+
&:is(:hover, :focus) {
|
|
517
|
+
color: $card-webcam-name-text--hover;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
.card-webcam__age {
|
|
522
|
+
@include font-sm;
|
|
523
|
+
|
|
524
|
+
flex-shrink: 0;
|
|
525
|
+
color: $card-webcam-meta-text;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
.card-webcam__meta {
|
|
529
|
+
@include font-xs;
|
|
530
|
+
|
|
531
|
+
display: flex;
|
|
532
|
+
align-items: center;
|
|
533
|
+
justify-content: space-between;
|
|
534
|
+
gap: 8px;
|
|
535
|
+
margin-top: 8px;
|
|
536
|
+
color: $card-webcam-meta-text;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
.card-webcam__viewers {
|
|
540
|
+
display: flex;
|
|
541
|
+
align-items: center;
|
|
542
|
+
gap: 4px;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
.card-webcam__country {
|
|
546
|
+
--line-clamp: 1;
|
|
547
|
+
|
|
548
|
+
&:is(:hover, :focus) {
|
|
549
|
+
color: $card-webcam-meta-text--hover;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
.card-webcam__meta:has(+ .card-webcam__tags) {
|
|
554
|
+
margin-bottom: 10px;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
.card-webcam__tags {
|
|
558
|
+
display: flex;
|
|
559
|
+
flex-wrap: wrap;
|
|
560
|
+
gap: 6px;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
.card-webcam__tag {
|
|
564
|
+
@include font-xs;
|
|
565
|
+
|
|
566
|
+
display: block;
|
|
567
|
+
padding: 4px 8px;
|
|
568
|
+
color: $card-webcam-tag-text;
|
|
569
|
+
background-color: $card-webcam-tag-bg;
|
|
570
|
+
border-radius: $radius-md;
|
|
571
|
+
|
|
572
|
+
&:is(:hover, :focus) {
|
|
573
|
+
color: $card-webcam-tag-text--hover;
|
|
574
|
+
background-color: $card-webcam-tag-bg--hover;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
</style>
|