itube-specs 0.0.950 → 0.0.952

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.
@@ -9,15 +9,9 @@
9
9
  на тег. Из нового: цель стримера и превью стрима появляются по ховеру, а на
10
10
  тач-устройствах превью включается удержанием тумбы, а уход из кадра его гасит. -->
11
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
- >
12
+ <article class="card-webcam">
13
+ <!-- Превью и цель включает сам постер, а не вся карточка: иначе ховер по имени,
14
+ стране или тегу под тумбой запускал трансляцию, хотя курсор не на кадре. -->
21
15
  <a
22
16
  ref="posterRef"
23
17
  :href="model.url"
@@ -25,6 +19,12 @@
25
19
  target="_blank"
26
20
  rel="nofollow noopener sponsored"
27
21
  @click="onClick"
22
+ @mouseenter="onEnter"
23
+ @mouseleave="onLeave"
24
+ @touchstart.passive="onTouchStart"
25
+ @touchmove.passive="onTouchMove"
26
+ @touchend="onTouchEnd"
27
+ @touchcancel="onTouchEnd"
28
28
  >
29
29
  <UiImg
30
30
  :src="poster"
@@ -74,7 +74,7 @@
74
74
 
75
75
  <span
76
76
  v-if="model.goal"
77
- :class="{'--visible': isPreviewActive}"
77
+ :class="{'--visible': isGoalVisible}"
78
78
  class="card-webcam__goal"
79
79
  >
80
80
  <span class="card-webcam__goal-text _truncate">
@@ -100,12 +100,15 @@
100
100
  :niche-path="nichePath"
101
101
  />
102
102
 
103
+ <!-- Ховер по имени показывает только цель: превью — дело постера. -->
103
104
  <a
104
105
  :href="model.url"
105
106
  class="card-webcam__name _truncate"
106
107
  target="_blank"
107
108
  rel="nofollow noopener sponsored"
108
109
  @click="onClick"
110
+ @mouseenter="onNameEnter"
111
+ @mouseleave="onNameLeave"
109
112
  >{{ model.name }}</a>
110
113
 
111
114
  <span
@@ -174,6 +177,7 @@
174
177
  <script setup lang="ts">
175
178
  import type { IWebcamModel } from '../../../types';
176
179
  import { isoCodeToCountryName } from '../../../utils/iso-code-to-country-name';
180
+ import { preloadHls } from '../../../utils/webcams/preload-hls';
177
181
 
178
182
  const props = withDefaults(defineProps<{
179
183
  model: IWebcamModel
@@ -249,10 +253,10 @@ function tagTitle(tag: string): string {
249
253
 
250
254
  /**
251
255
  * Превью — живой HLS-поток модели (`.m3u8`), а не файл: нативно его понимает
252
- * только Safari, остальным нужен hls.js. Библиотека грузится динамически и
253
- * только при первом превью — она весит больше, чем весь блок, и на большинстве
254
- * показов не нужна вовсе. Поток отпускаем сразу после ухода курсора: тумб на
255
- * странице много, и держать открытыми несколько трансляций незачем.
256
+ * только Safari, остальным нужен hls.js. Библиотеку тянем не с бандлом, а по
257
+ * появлению тумб на экране (`preloadOnVisible` ниже) — к моменту ховера она уже в
258
+ * пути. Поток отпускаем сразу после ухода курсора: тумб на странице много, и
259
+ * держать открытыми несколько трансляций незачем.
256
260
  *
257
261
  * Воспроизведение запускаем руками, как это делал боевой движок: атрибута
258
262
  * `autoplay` для потока, подключённого через MSE, недостаточно.
@@ -270,7 +274,7 @@ async function startPreview() {
270
274
  const video = videoRef.value;
271
275
  if (!video || !isPreviewActive.value) return;
272
276
 
273
- const { default: Hls } = await import('hls.js');
277
+ const { default: Hls } = await preloadHls();
274
278
  if (!isPreviewActive.value) return;
275
279
 
276
280
  // порядок важен: Chrome на `canPlayType('…mpegurl')` отвечает «maybe», но HLS
@@ -314,6 +318,24 @@ function onLeave() {
314
318
  previewSlot.release(previewId.value);
315
319
  }
316
320
 
321
+ /**
322
+ * Цель стримера появляется в двух случаях: при превью (ховер по постеру) и просто
323
+ * при ховере по имени модели — там поток не нужен, а прочитать цель хочется.
324
+ */
325
+ const isNameHovered = ref(false);
326
+
327
+ const isGoalVisible = computed(() => isPreviewActive.value || isNameHovered.value);
328
+
329
+ function onNameEnter() {
330
+ if (props.touchPreview) return;
331
+
332
+ isNameHovered.value = true;
333
+ }
334
+
335
+ function onNameLeave() {
336
+ isNameHovered.value = false;
337
+ }
338
+
317
339
  /**
318
340
  * Тач: превью включает удержание тумбы — короткий тап должен открывать модель, а
319
341
  * не запускать поток. Движение пальцем считаем скроллом и удержание отменяем.
@@ -372,9 +394,31 @@ watch(isPreviewActive, (active) => {
372
394
  });
373
395
 
374
396
  let observer: IntersectionObserver | null = null;
397
+ let preloadObserver: IntersectionObserver | null = null;
398
+
399
+ /**
400
+ * hls.js не нужен, пока тумбы не доехали до экрана: на длинной странице секция
401
+ * вебкамов может остаться непрочитанной. Первая попавшая в кадр тумба запускает
402
+ * общую загрузку с запасом в 300px — в разделе вебкамов сетка видна сразу, там это
403
+ * срабатывает на первом же кадре.
404
+ */
405
+ function preloadOnVisible(element: HTMLElement) {
406
+ preloadObserver = new IntersectionObserver((entries) => {
407
+ if (!entries.some(entry => entry.isIntersecting)) return;
408
+
409
+ preloadHls();
410
+ preloadObserver?.disconnect();
411
+ preloadObserver = null;
412
+ }, { rootMargin: '300px' });
413
+
414
+ preloadObserver.observe(element);
415
+ }
375
416
 
376
417
  onMounted(() => {
377
- if (posterRef.value) emit('poster', posterRef.value);
418
+ if (posterRef.value) {
419
+ emit('poster', posterRef.value);
420
+ preloadOnVisible(posterRef.value);
421
+ }
378
422
 
379
423
  if (!props.touchPreview || !posterRef.value) return;
380
424
 
@@ -390,6 +434,7 @@ onMounted(() => {
390
434
 
391
435
  onBeforeUnmount(() => {
392
436
  observer?.disconnect();
437
+ preloadObserver?.disconnect();
393
438
  observer = null;
394
439
  previewSlot.release(previewId.value);
395
440
  stopPreview();
@@ -1,5 +1,9 @@
1
1
  <!-- Языки вебкам-модели: флаги-ссылки на фильтр раздела вебкамов.
2
2
 
3
+ Ссылка идёт в НАШ query-параметр фильтра (`filter_language`), а не в провайдерский
4
+ `mf_*`: состояние фильтров раздела живёт в общих `filter_*`, и по `mf_language`
5
+ раздел открывался вообще без фильтра.
6
+
3
7
  Логика повторяет боевой движок: первым идёт язык пользователя, если модель на нём
4
8
  говорит. Флагов показываем не больше двух — у иных моделей языков с десяток.
5
9
 
@@ -15,7 +19,7 @@
15
19
  <a
16
20
  v-for="item in items"
17
21
  :key="`lang-${item.lang}`"
18
- :href="`${nichePath}/webcams?mf_language=${item.lang}`"
22
+ :href="`${nichePath}/webcams?${WEBCAM_FILTER_QUERY_PREFIX}language=${item.lang}`"
19
23
  :aria-label="item.lang"
20
24
  class="card-webcam-languages__link"
21
25
  >
@@ -42,6 +46,7 @@
42
46
 
43
47
  <script setup lang="ts">
44
48
  import { LANG_TO_FLAG } from '../../../utils/webcams/lang-to-flag';
49
+ import { WEBCAM_FILTER_QUERY_PREFIX } from '../../../utils/webcams/facets';
45
50
 
46
51
  const props = withDefaults(defineProps<{
47
52
  /** Языки модели (ISO-коды) */
@@ -211,7 +211,7 @@ function onChipsClick(item: IFilterChipItem) {
211
211
  } else {
212
212
  delete query[`${item.value}`];
213
213
  }
214
- router.replace({ query });
214
+ router.replace({ path: route.path, query });
215
215
  }
216
216
 
217
217
  function onPendingChipsClick(item: IFilterChipItem) {
@@ -227,7 +227,7 @@ function onClearAllClick() {
227
227
  .forEach(key => delete query[key]);
228
228
  delete query['page'];
229
229
 
230
- router.replace({ query });
230
+ router.replace({ path: route.path, query });
231
231
  }
232
232
  </script>
233
233
 
@@ -106,7 +106,7 @@ watch(units, () => {
106
106
  delete query[`filter_${field}_from`];
107
107
  delete query[`filter_${field}_to`];
108
108
  }
109
- router.replace({ query });
109
+ router.replace({ path: route.path, query });
110
110
  });
111
111
 
112
112
  const unitItems = computed(() => [
@@ -140,7 +140,7 @@ function updateFilter(name: string, val: any) {
140
140
  query[ `filter_${name}` ] = val;
141
141
  }
142
142
 
143
- router.replace({ query });
143
+ router.replace({ path: route.path, query });
144
144
  }
145
145
 
146
146
  function isActiveSelect(name: string) {
@@ -118,6 +118,14 @@ const props = defineProps<{
118
118
  /** Где живёт состояние: свой query-параметр `filter_<name>` (модели, вебкамы) или общая
119
119
  * строка `categories=<группа>_<значение>` (каталог видео, синхронно с попапом фильтра). */
120
120
  mode?: 'param' | 'categories'
121
+ /**
122
+ * Своя подпись ВЫБРАННОГО значения на кнопке селекта. Без пропса — обычное поведение
123
+ * (название выбранной опции). Строка — шаблон с `{value}` (например `{value} speaking`),
124
+ * функция получает фильтр и выбранную опцию и может вернуть пусто — тогда подпись
125
+ * стандартная. Нужна там, где выбранное значение читается фразой: в разделе вебкамов
126
+ * язык на кнопке и в чипсе выглядит как «Russian speaking».
127
+ */
128
+ selectedLabel?: string | ((filter: IModelFilter, option: IModelFilterOptions) => string | undefined)
121
129
  }>();
122
130
 
123
131
  const route = useRoute();
@@ -180,7 +188,7 @@ function onChange(name: string) {
180
188
 
181
189
  if (isReset) selectValues.value[name] = getSelectValue(name, query);
182
190
 
183
- router.replace({ query });
191
+ router.replace({ path: route.path, query });
184
192
  }
185
193
 
186
194
  // Активный слайдер
@@ -300,7 +308,18 @@ function getSelectLabel(item: IModelFilter) {
300
308
  const current = readQueryValue(item.name, route.query);
301
309
  if (!current) return item.title;
302
310
  const opt = item.options.find(o => o.name === current);
303
- return opt ? capitalize(opt.title) : item.title;
311
+ if (!opt) return item.title;
312
+
313
+ return overrideSelectedLabel(item, opt) || capitalize(opt.title);
314
+ }
315
+
316
+ /** Подпись выбранного значения из пропса `selectedLabel`; пусто — берётся стандартная. */
317
+ function overrideSelectedLabel(item: IModelFilter, option: IModelFilterOptions) {
318
+ if (!props.selectedLabel) return '';
319
+
320
+ if (typeof props.selectedLabel === 'function') return props.selectedLabel(item, option) || '';
321
+
322
+ return props.selectedLabel.replace('{value}', capitalize(option.title));
304
323
  }
305
324
 
306
325
  // Количество бек считает не для всех опций (у синтетической «all» его нет) — скобки без числа не рисуем
@@ -61,7 +61,7 @@ function commitQuery(newQuery: LocationQuery) {
61
61
  if (props.query !== undefined) {
62
62
  emit('update:query', newQuery);
63
63
  } else {
64
- router.replace({ query: newQuery });
64
+ router.replace({ path: route.path, query: newQuery });
65
65
  }
66
66
  }
67
67
 
@@ -26,19 +26,13 @@ const props = defineProps<{
26
26
  scheme: IFilterScheme[]
27
27
  }>();
28
28
 
29
- const quickFilterScheme = useAppConfig().quickVideoFilters || [];
30
-
31
29
  const findGroup = (name: string) => props.scheme.find(item => item.name.toLowerCase() === name.toLowerCase());
32
30
 
33
31
  /**
34
- * Название группы для подписи чипсы. Сначала короткий заголовок из конфига сайта — тот же,
35
- * что стоит на кнопке в строке главных фильтров, иначе название группы с бека. Схема грузится
36
- * лениво, и до неё остаётся только ключ из query.
32
+ * Название группы для подписи чипсы — как его отдал бек. Схема грузится лениво,
33
+ * и до неё остаётся только ключ из query.
37
34
  */
38
35
  function groupLabel(name: string) {
39
- const quick = quickFilterScheme.find(item => item.name.toLowerCase() === name.toLowerCase());
40
- if (quick?.titleKey) return t(quick.titleKey);
41
-
42
36
  return findGroup(name)?.title || convertString().toCapitalize(name);
43
37
  }
44
38
 
@@ -113,7 +107,7 @@ function onChipsClick(item: IFilterChipItem) {
113
107
 
114
108
  removeChipFromQuery(query, item);
115
109
 
116
- router.replace({ query });
110
+ router.replace({ path: route.path, query });
117
111
  }
118
112
 
119
113
  /** «Сбросить всё»: снимает фильтры, сортировку не трогает. */
@@ -123,6 +117,6 @@ function onClearAllClick() {
123
117
  chipsItems.value.forEach(item => removeChipFromQuery(query, item));
124
118
  delete query['page'];
125
119
 
126
- router.replace({ query });
120
+ router.replace({ path: route.path, query });
127
121
  }
128
122
  </script>
@@ -164,22 +164,6 @@ const modelsRoundCards = computed(() =>
164
164
  grid-column: 2;
165
165
  grid-row: 1;
166
166
  }
167
-
168
- @include to-br(sm) {
169
- flex-wrap: nowrap;
170
- max-width: 100vw;
171
- margin-inline: calc(-1 * var(--container-padding-inline));
172
- padding-inline: var(--container-padding-inline);
173
- overflow: auto;
174
-
175
- &::-webkit-scrollbar {
176
- display: none;
177
- }
178
- }
179
- }
180
-
181
- .view-videos__quick .filter-selects__wrapper {
182
- flex-shrink: 0;
183
167
  }
184
168
 
185
169
  // до чипсов воздуха больше, чем между сортировкой и фильтрами
@@ -53,6 +53,7 @@ query-параметрах `filter_*`. Формат провайдера при
53
53
  <FilterSelects
54
54
  v-if="filters.length"
55
55
  :filters="filters"
56
+ :selected-label="selectedFilterLabel"
56
57
  />
57
58
  </div>
58
59
  </div>
@@ -114,11 +115,11 @@ query-параметрах `filter_*`. Формат провайдера при
114
115
  </template>
115
116
 
116
117
  <script setup lang="ts">
117
- import type { IModelCard, IModelFilter, ISwitcherItem, IStripcashModel, IWebcamModelsResponse, PaginatedResponse } from '../../types';
118
+ import type { IModelCard, IModelFilter, IModelFilterOptions, ISwitcherItem, IStripcashModel, IWebcamModelsResponse, PaginatedResponse } from '../../types';
118
119
  import { countWebcamFilters } from '../../utils/webcams/count-filters';
119
120
  import { normalizeWebcamFacets } from '../../utils/webcams/normalize-facets';
120
121
  import { toWebcamFilterPayload } from '../../utils/webcams/to-filter-payload';
121
- import { WEBCAM_FILTER_ANY } from '../../utils/webcams/facets';
122
+ import { WEBCAM_FILTER_ANY, WEBCAM_LANGUAGE_FACET } from '../../utils/webcams/facets';
122
123
  import { sortWebcams } from '../../lib';
123
124
 
124
125
  const props = defineProps<{
@@ -157,17 +158,42 @@ const items = computed(() => props.data?.items || []);
157
158
  const allPages = computed(() => props.data?.allPages || 0);
158
159
  const filterCount = computed(() => countWebcamFilters(route.query));
159
160
 
161
+ const currentFacets = computed(() => normalizeWebcamFacets(props.data?.filters, props.data?.total));
162
+
163
+ /**
164
+ * Последние непустые фасеты держим у себя: при нулевом результате провайдер
165
+ * `filters` не присылает, и строка фильтров с чипсами исчезала бы вместе с выдачей —
166
+ * поправить условия было бы нечем, оставался бы только полный сброс.
167
+ */
168
+ const lastFacets = ref<IModelFilter[]>([]);
169
+
170
+ watchEffect(() => {
171
+ if (currentFacets.value.length) lastFacets.value = currentFacets.value;
172
+ });
173
+
160
174
  /**
161
175
  * Фасеты провайдера → фильтры слоя. Подписи фильтров переводим по имени (провайдер
162
176
  * отдаёт их только по-английски), варианты оставляем как есть — их у него сотни.
163
177
  */
164
- const filters = computed<IModelFilter[]>(() => normalizeWebcamFacets(props.data?.filters, props.data?.total).map(filter => ({
178
+ const filters = computed<IModelFilter[]>(() => (currentFacets.value.length ? currentFacets.value : lastFacets.value).map(filter => ({
165
179
  ...filter,
166
180
  title: translate(`webcams.filters.${filter.name}`, filter.title),
167
181
  options: filter.options.map(option => (option.name === WEBCAM_FILTER_ANY ? { ...option, title: t('all') } : option)),
168
182
  group: { ...filter.group, title: translate(`webcams.filter_groups.${filter.group.name}`, filter.group.name) },
169
183
  })));
170
184
 
185
+ /**
186
+ * Выбранный язык на кнопке селекта читается фразой — «Russian speaking», как в макете
187
+ * и как в чипсе облака фильтров. Остальные фильтры стандартные: у них значение и так
188
+ * самодостаточное («Украина», «18-24»). Ключа у сайта может не быть — тогда останется
189
+ * просто название языка.
190
+ */
191
+ function selectedFilterLabel(filter: IModelFilter, option: IModelFilterOptions) {
192
+ if (filter.name !== WEBCAM_LANGUAGE_FACET || !te('webcams.speaking')) return '';
193
+
194
+ return t('webcams.speaking', { lang: option.title });
195
+ }
196
+
171
197
  /** Модалка фильтра слоя ждёт ответ листинга — ей нужны только фасеты. */
172
198
  const filterData = computed(() => ({ filters: filters.value }) as unknown as PaginatedResponse<IModelCard>);
173
199
 
@@ -3,11 +3,9 @@ import type { LocationQuery } from '#vue-router';
3
3
  import { getMonth } from '../runtime';
4
4
  import { buildChipParts } from '../utils/filter-chips/build-chip-parts';
5
5
  import { LANG_TO_FLAG } from '../utils/webcams/lang-to-flag';
6
+ import { WEBCAM_LANGUAGE_FACET } from '../utils/webcams/facets';
6
7
  import type { Ref } from 'vue';
7
8
 
8
- /** Единственный фильтр-язык у нас в вебкамах: у моделей такого поля нет. */
9
- const LANGUAGE_FILTER = 'language';
10
-
11
9
  export function useFilterChipsItems(
12
10
  filters: Ref<IModelFilter[]>,
13
11
  route: { query: LocationQuery },
@@ -95,7 +93,7 @@ export function useFilterChipsItems(
95
93
  * флага. Если у сайта ключа перевода ещё нет, остаётся название языка.
96
94
  */
97
95
  const buildLanguageChip = (valueText: string) => {
98
- const code = String(route.query[`filter_${LANGUAGE_FILTER}`] || '').toLowerCase();
96
+ const code = String(route.query[`filter_${WEBCAM_LANGUAGE_FACET}`] || '').toLowerCase();
99
97
 
100
98
  return {
101
99
  title: t('webcams.speaking', { lang: valueText }) || valueText,
@@ -110,7 +108,7 @@ export function useFilterChipsItems(
110
108
  const valueText = getValueText(item);
111
109
  if (!valueText) return null;
112
110
  return {
113
- ...(filter.name === LANGUAGE_FILTER
111
+ ...(filter.name === WEBCAM_LANGUAGE_FACET
114
112
  ? buildLanguageChip(valueText)
115
113
  : buildChipParts(filter.title, valueText)),
116
114
  value: item.map(
@@ -76,8 +76,6 @@
76
76
  "tatoos": "Tattoos",
77
77
  "orientation": "Orientation"
78
78
  },
79
- "quick_filter_nationality": "Nationality",
80
- "quick_filter_participants": "Participants",
81
79
  "language_and_region": "Language & Region",
82
80
  "all_models": "All models",
83
81
  "user_playlists": "User playlists",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.950",
4
+ "version": "0.0.952",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
@@ -12,11 +12,9 @@ export interface IQuickFilterGroup {
12
12
  categories: IQuickFilterCategory[]
13
13
  }
14
14
 
15
- /** Описание квик-фильтра каталога видео: имя группы категорий и, если название
16
- * группы с бека не подходит для короткой кнопки, свой i18n-ключ заголовка. */
15
+ /** Описание квик-фильтра каталога видео: имя группы категорий с бека. */
17
16
  export interface IQuickFilterScheme {
18
17
  name: string
19
- titleKey?: string
20
18
  /** Убрать кнопку из строки на узких экранах (до `sm`) — фильтр остаётся в попапе. */
21
19
  hideOnMobile?: boolean
22
20
  }
@@ -45,10 +45,10 @@ describe('toQuickFilters', () => {
45
45
  });
46
46
  });
47
47
 
48
- it('заголовок берёт из titleKey, когда название группы не подходит', () => {
49
- const [filter] = toQuickFilters(groups, [{ name: 'PPL Involved', titleKey: 'participants' }], translate);
48
+ it('заголовок берёт с бека, а не из схемы', () => {
49
+ const [filter] = toQuickFilters(groups, [{ name: 'PPL Involved' }], translate);
50
50
 
51
- expect(filter?.title).toBe('t:participants');
51
+ expect(filter?.title).toBe('PPL Involved');
52
52
  });
53
53
 
54
54
  it('разворачивает hideOnMobile схемы в showOnMobile по каждому фильтру', () => {
@@ -13,7 +13,7 @@ import type { IModelFilter, IQuickFilterGroup, IQuickFilterScheme } from '../../
13
13
  *
14
14
  * @param groups - группы категорий с бека
15
15
  * @param scheme - какие группы показывать и в каком порядке
16
- * @param translate - `t` из `useI18n` для заголовков из `titleKey` и опции «all»
16
+ * @param translate - `t` из `useI18n` для опции «all»
17
17
  */
18
18
  export function toQuickFilters(
19
19
  groups: IQuickFilterGroup[],
@@ -28,7 +28,7 @@ export function toQuickFilters(
28
28
  return {
29
29
  kind: 'select',
30
30
  name: group.name.toLowerCase(),
31
- title: item.titleKey ? translate(item.titleKey) : group.title,
31
+ title: group.title,
32
32
  icon: '',
33
33
  show_on_form: true,
34
34
  group: { name: 'quick', title: 'quick', description: '', order: 0 },
@@ -24,6 +24,9 @@ export const WEBCAM_QUICK_FACETS = ['age', 'language', 'country', 'specifics'];
24
24
  /** Значение селекта «любой» — слой по нему удаляет параметр из query. */
25
25
  export const WEBCAM_FILTER_ANY = 'all';
26
26
 
27
+ /** Имя языкового фасета: у него подпись выбранного значения читается фразой («Russian speaking»). */
28
+ export const WEBCAM_LANGUAGE_FACET = 'language';
29
+
27
30
  /** Группы фильтров: строка фильтров и всё остальное. */
28
31
  export const WEBCAM_FILTER_GROUPS = {
29
32
  quick: { name: 'quick', order: 0 },
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Один общий импорт hls.js на страницу.
3
+ *
4
+ * Библиотека весит больше, чем весь вебкам-блок, и на большинстве показов не нужна
5
+ * вовсе, поэтому её не тянут ни с бандлом страницы, ни при монтировании тумб —
6
+ * только когда тумбы реально появились на экране (см. `CardWebcam`). Промис
7
+ * кешируется: сколько бы тумб ни попало в кадр, запрос уйдёт один, а превью по
8
+ * ховеру дождётся уже начатой загрузки вместо своей.
9
+ */
10
+ let hlsModule: Promise<typeof import('hls.js')> | null = null;
11
+
12
+ export function preloadHls() {
13
+ if (!hlsModule) hlsModule = import('hls.js');
14
+
15
+ return hlsModule;
16
+ }