itube-specs 0.0.949 → 0.0.951

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"
@@ -174,6 +174,7 @@
174
174
  <script setup lang="ts">
175
175
  import type { IWebcamModel } from '../../../types';
176
176
  import { isoCodeToCountryName } from '../../../utils/iso-code-to-country-name';
177
+ import { preloadHls } from '../../../utils/webcams/preload-hls';
177
178
 
178
179
  const props = withDefaults(defineProps<{
179
180
  model: IWebcamModel
@@ -249,10 +250,10 @@ function tagTitle(tag: string): string {
249
250
 
250
251
  /**
251
252
  * Превью — живой HLS-поток модели (`.m3u8`), а не файл: нативно его понимает
252
- * только Safari, остальным нужен hls.js. Библиотека грузится динамически и
253
- * только при первом превью — она весит больше, чем весь блок, и на большинстве
254
- * показов не нужна вовсе. Поток отпускаем сразу после ухода курсора: тумб на
255
- * странице много, и держать открытыми несколько трансляций незачем.
253
+ * только Safari, остальным нужен hls.js. Библиотеку тянем не с бандлом, а по
254
+ * появлению тумб на экране (`preloadOnVisible` ниже) — к моменту ховера она уже в
255
+ * пути. Поток отпускаем сразу после ухода курсора: тумб на странице много, и
256
+ * держать открытыми несколько трансляций незачем.
256
257
  *
257
258
  * Воспроизведение запускаем руками, как это делал боевой движок: атрибута
258
259
  * `autoplay` для потока, подключённого через MSE, недостаточно.
@@ -270,7 +271,7 @@ async function startPreview() {
270
271
  const video = videoRef.value;
271
272
  if (!video || !isPreviewActive.value) return;
272
273
 
273
- const { default: Hls } = await import('hls.js');
274
+ const { default: Hls } = await preloadHls();
274
275
  if (!isPreviewActive.value) return;
275
276
 
276
277
  // порядок важен: Chrome на `canPlayType('…mpegurl')` отвечает «maybe», но HLS
@@ -372,9 +373,31 @@ watch(isPreviewActive, (active) => {
372
373
  });
373
374
 
374
375
  let observer: IntersectionObserver | null = null;
376
+ let preloadObserver: IntersectionObserver | null = null;
377
+
378
+ /**
379
+ * hls.js не нужен, пока тумбы не доехали до экрана: на длинной странице секция
380
+ * вебкамов может остаться непрочитанной. Первая попавшая в кадр тумба запускает
381
+ * общую загрузку с запасом в 300px — в разделе вебкамов сетка видна сразу, там это
382
+ * срабатывает на первом же кадре.
383
+ */
384
+ function preloadOnVisible(element: HTMLElement) {
385
+ preloadObserver = new IntersectionObserver((entries) => {
386
+ if (!entries.some(entry => entry.isIntersecting)) return;
387
+
388
+ preloadHls();
389
+ preloadObserver?.disconnect();
390
+ preloadObserver = null;
391
+ }, { rootMargin: '300px' });
392
+
393
+ preloadObserver.observe(element);
394
+ }
375
395
 
376
396
  onMounted(() => {
377
- if (posterRef.value) emit('poster', posterRef.value);
397
+ if (posterRef.value) {
398
+ emit('poster', posterRef.value);
399
+ preloadOnVisible(posterRef.value);
400
+ }
378
401
 
379
402
  if (!props.touchPreview || !posterRef.value) return;
380
403
 
@@ -390,6 +413,7 @@ onMounted(() => {
390
413
 
391
414
  onBeforeUnmount(() => {
392
415
  observer?.disconnect();
416
+ preloadObserver?.disconnect();
393
417
  observer = null;
394
418
  previewSlot.release(previewId.value);
395
419
  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) {
@@ -180,7 +180,7 @@ function onChange(name: string) {
180
180
 
181
181
  if (isReset) selectValues.value[name] = getSelectValue(name, query);
182
182
 
183
- router.replace({ query });
183
+ router.replace({ path: route.path, query });
184
184
  }
185
185
 
186
186
  // Активный слайдер
@@ -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>
@@ -130,6 +130,7 @@
130
130
  <script setup lang="ts">
131
131
  import type { ICountry, IGender, IProfileData, ISelectItem } from '../../types';
132
132
  import { compressImage, convertDateToTimestamp, validateEmail } from '../../runtime';
133
+ import { isoCodeToCountryName } from '../../utils/iso-code-to-country-name';
133
134
  import { AuthorizationApiService } from '../../services/api/authorization.service';
134
135
  import { DictionariesApiService } from '../../services/api/dictionaries.service';
135
136
 
@@ -224,10 +225,20 @@ const [
224
225
  useFetchDictionariesGenders(DictionariesApiService.getGenders),
225
226
  ]);
226
227
 
227
- const countriesItems = computed<ISelectItem[] | undefined>(() => countriesData.value?.countries.map(item => ({
228
- title: item.title,
229
- value: item.title,
230
- })));
228
+ // Список стран приходит с API только по-английски — подписываем его ICU-словарём и
229
+ // пересортировываем коллатором, как в layout/language.vue. `value` остаётся английским
230
+ // title: по нему findCountry() ищет страну и им же инициализируется поле из профиля.
231
+ const countriesItems = computed<ISelectItem[] | undefined>(() => {
232
+ const collator = new Intl.Collator(locale.value);
233
+
234
+ return countriesData.value?.countries
235
+ .map((item) => {
236
+ const code = (item.isoCodes?.[0] || '').toUpperCase();
237
+ const name = isoCodeToCountryName(code, locale.value);
238
+ return { title: name === code ? item.title : name, value: item.title };
239
+ })
240
+ .sort((a, b) => collator.compare(a.title, b.title));
241
+ });
231
242
 
232
243
  const gendersItems = computed<ISelectItem[] | undefined>(() => gendersData.value?.genders.map(item => ({
233
244
  title: item.title,
@@ -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
  // до чипсов воздуха больше, чем между сортировкой и фильтрами
@@ -157,11 +157,24 @@ const items = computed(() => props.data?.items || []);
157
157
  const allPages = computed(() => props.data?.allPages || 0);
158
158
  const filterCount = computed(() => countWebcamFilters(route.query));
159
159
 
160
+ const currentFacets = computed(() => normalizeWebcamFacets(props.data?.filters, props.data?.total));
161
+
162
+ /**
163
+ * Последние непустые фасеты держим у себя: при нулевом результате провайдер
164
+ * `filters` не присылает, и строка фильтров с чипсами исчезала бы вместе с выдачей —
165
+ * поправить условия было бы нечем, оставался бы только полный сброс.
166
+ */
167
+ const lastFacets = ref<IModelFilter[]>([]);
168
+
169
+ watchEffect(() => {
170
+ if (currentFacets.value.length) lastFacets.value = currentFacets.value;
171
+ });
172
+
160
173
  /**
161
174
  * Фасеты провайдера → фильтры слоя. Подписи фильтров переводим по имени (провайдер
162
175
  * отдаёт их только по-английски), варианты оставляем как есть — их у него сотни.
163
176
  */
164
- const filters = computed<IModelFilter[]>(() => normalizeWebcamFacets(props.data?.filters, props.data?.total).map(filter => ({
177
+ const filters = computed<IModelFilter[]>(() => (currentFacets.value.length ? currentFacets.value : lastFacets.value).map(filter => ({
165
178
  ...filter,
166
179
  title: translate(`webcams.filters.${filter.name}`, filter.title),
167
180
  options: filter.options.map(option => (option.name === WEBCAM_FILTER_ANY ? { ...option, title: t('all') } : option)),
@@ -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.949",
4
+ "version": "0.0.951",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
@@ -64,8 +64,9 @@
64
64
  </template>
65
65
 
66
66
  <script setup lang="ts">
67
- import type { IProfileData } from '../../types';
67
+ import type { ICountry, IProfileData } from '../../types';
68
68
  import { convertDateToTimestamp } from '../../runtime';
69
+ import { isoCodeToCountryName } from '../../utils/iso-code-to-country-name';
69
70
 
70
71
  interface IDataItem {
71
72
  title: string
@@ -86,11 +87,24 @@ const memberSince = computed(() => {
86
87
  return new Intl.DateTimeFormat(locale.value, { month: 'long', year: 'numeric' }).format(date);
87
88
  });
88
89
 
90
+ // Страна с API приходит объектом словаря (тип профиля знает её как строку) — подписываем её
91
+ // тем же ICU-словарём, что селектор в шапке и форму профиля. Строку отдаём как есть: имени
92
+ // без ISO-кода не перевести.
93
+ const countryName = computed(() => {
94
+ const country = data.value.country as unknown as ICountry | string | null;
95
+ if (!country) return '';
96
+ if (typeof country === 'string') return country;
97
+
98
+ const code = (country.isoCodes?.[0] || '').toUpperCase();
99
+ const name = isoCodeToCountryName(code, locale.value);
100
+ return name === code ? country.title : name;
101
+ });
102
+
89
103
  const items = computed<Array<IDataItem>>(() => {
90
104
  return [
91
105
  {
92
106
  title: t('profile.country'),
93
- text: data.value.country?.title ?? '',
107
+ text: countryName.value,
94
108
  icon: 'geo'
95
109
  },
96
110
  {
@@ -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 },
@@ -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
+ }