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.
- package/components/card/webcam/index.vue +39 -15
- package/components/card/webcam/languages.vue +6 -1
- package/components/filter/model.vue +2 -2
- package/components/filter/page.vue +2 -2
- package/components/filter/selects.vue +1 -1
- package/components/filter/slider.vue +1 -1
- package/components/filter/videos-chips.vue +4 -10
- package/components/profile/setup.vue +15 -4
- package/components/view/videos.vue +0 -16
- package/components/view/webcams.vue +14 -1
- package/i18n/locales/en-US.json +0 -2
- package/package.json +1 -1
- package/pages/user/profile.vue +16 -2
- package/types/quick-filter.d.ts +1 -3
- package/utils/filter-selects/__tests__/to-quick-filters.test.ts +3 -3
- package/utils/filter-selects/to-quick-filters.ts +2 -2
- package/utils/webcams/preload-hls.ts +16 -0
|
@@ -9,15 +9,9 @@
|
|
|
9
9
|
на тег. Из нового: цель стримера и превью стрима появляются по ховеру, а на
|
|
10
10
|
тач-устройствах превью включается удержанием тумбы, а уход из кадра его гасит. -->
|
|
11
11
|
<template>
|
|
12
|
-
<article
|
|
13
|
-
|
|
14
|
-
|
|
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
|
|
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)
|
|
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
|
|
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) {
|
|
@@ -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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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[]>(() =>
|
|
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)),
|
package/i18n/locales/en-US.json
CHANGED
|
@@ -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
package/pages/user/profile.vue
CHANGED
|
@@ -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:
|
|
107
|
+
text: countryName.value,
|
|
94
108
|
icon: 'geo'
|
|
95
109
|
},
|
|
96
110
|
{
|
package/types/quick-filter.d.ts
CHANGED
|
@@ -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('заголовок берёт
|
|
49
|
-
const [filter] = toQuickFilters(groups, [{ name: 'PPL Involved'
|
|
48
|
+
it('заголовок берёт с бека, а не из схемы', () => {
|
|
49
|
+
const [filter] = toQuickFilters(groups, [{ name: 'PPL Involved' }], translate);
|
|
50
50
|
|
|
51
|
-
expect(filter?.title).toBe('
|
|
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` для
|
|
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:
|
|
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
|
+
}
|