itube-specs 0.0.964 → 0.0.965
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 +11 -0
- package/assets/scss/themes/light/_theme.scss +11 -0
- package/components/player/aside-models.vue +219 -0
- package/components/player/aside-videos.vue +31 -6
- package/components/player/main.vue +4 -3
- package/composables/fetch/use-fetch-aside-models.ts +72 -0
- package/composables/use-webcam-alive.ts +30 -4
- package/package.json +1 -1
- package/pages/videos/[slug].vue +13 -8
|
@@ -865,6 +865,17 @@ $bg-fade-90: $color-zinc-950-90; // почти непрозрачный
|
|
|
865
865
|
|
|
866
866
|
// ─── Оверлей карточек ───
|
|
867
867
|
|
|
868
|
+
// ─── Player aside models ───
|
|
869
|
+
$aside-models-name: $color-white;
|
|
870
|
+
$aside-models-name--hover: $color-indigo-400;
|
|
871
|
+
$aside-models-params: $color-zinc-400;
|
|
872
|
+
$aside-models-separator: $color-zinc-500;
|
|
873
|
+
$aside-models-count: $color-zinc-500;
|
|
874
|
+
$aside-models-avatar-bg: $color-zinc-800;
|
|
875
|
+
$aside-models-avatar-text: $color-zinc-400;
|
|
876
|
+
$aside-models-avatar-ring: $color-zinc-700;
|
|
877
|
+
$aside-models-avatar-ring--hover: $color-zinc-500;
|
|
878
|
+
|
|
868
879
|
// ─── Model card ───
|
|
869
880
|
$model-card-bg: $color-zinc-900-50;
|
|
870
881
|
$model-card-bg--hover: $color-zinc-800-50;
|
|
@@ -891,6 +891,17 @@ $bg-fade-90: $color-gray-950-90; // почти непрозрачный
|
|
|
891
891
|
|
|
892
892
|
// ─── Оверлей карточек ───
|
|
893
893
|
|
|
894
|
+
// ─── Player aside models ───
|
|
895
|
+
$aside-models-name: $color-gray-900;
|
|
896
|
+
$aside-models-name--hover: $color-indigo-600;
|
|
897
|
+
$aside-models-params: $color-gray-600;
|
|
898
|
+
$aside-models-separator: $color-gray-400;
|
|
899
|
+
$aside-models-count: $color-gray-500;
|
|
900
|
+
$aside-models-avatar-bg: $color-gray-200;
|
|
901
|
+
$aside-models-avatar-text: $color-gray-500;
|
|
902
|
+
$aside-models-avatar-ring: $color-gray-300;
|
|
903
|
+
$aside-models-avatar-ring--hover: $color-gray-400;
|
|
904
|
+
|
|
894
905
|
// ─── Model card ───
|
|
895
906
|
$model-card-bg: $color-gray-100;
|
|
896
907
|
$model-card-bg--hover: $model-card-bg;
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
<!-- Блок одной модели ролика в боковой колонке плеера: шапка со ссылкой на модель
|
|
2
|
+
(аватар, имя с флагами, параметры и счётчик роликов) и сетка её роликов 2×2.
|
|
3
|
+
Ролики — обычные мини-карточки, поэтому превью, меню и ссылки у них те же, что
|
|
4
|
+
в остальных сетках. -->
|
|
5
|
+
<template>
|
|
6
|
+
<section class="player-aside-models">
|
|
7
|
+
<NuxtLink
|
|
8
|
+
:to="generateLink(`/models/${slug}`)"
|
|
9
|
+
:title="title"
|
|
10
|
+
class="player-aside-models__head"
|
|
11
|
+
>
|
|
12
|
+
<UiImg
|
|
13
|
+
v-if="avatar"
|
|
14
|
+
:src="avatar"
|
|
15
|
+
:alt="title"
|
|
16
|
+
class="player-aside-models__avatar"
|
|
17
|
+
width="56"
|
|
18
|
+
height="56"
|
|
19
|
+
sizes="56px"
|
|
20
|
+
loading="lazy"
|
|
21
|
+
@error="avatarError = true"
|
|
22
|
+
/>
|
|
23
|
+
<span
|
|
24
|
+
v-else
|
|
25
|
+
class="player-aside-models__avatar --empty"
|
|
26
|
+
>{{ title.slice(0, 1) }}</span>
|
|
27
|
+
|
|
28
|
+
<span class="player-aside-models__info">
|
|
29
|
+
<span class="player-aside-models__name-row">
|
|
30
|
+
<span class="player-aside-models__name">{{ title }}</span>
|
|
31
|
+
<!-- Национальность нигде больше не подписана — флаг её единственный носитель,
|
|
32
|
+
поэтому в `alt` идёт название страны, а не ISO-код. -->
|
|
33
|
+
<img
|
|
34
|
+
v-for="code in info?.nationalityIsoCodes || []"
|
|
35
|
+
:key="`flag-${code}`"
|
|
36
|
+
:src="`/icons/flags/${code}.svg`"
|
|
37
|
+
:alt="isoCodeToCountryName(code, locale)"
|
|
38
|
+
class="player-aside-models__flag"
|
|
39
|
+
width="16"
|
|
40
|
+
height="12"
|
|
41
|
+
>
|
|
42
|
+
</span>
|
|
43
|
+
<span class="player-aside-models__params">
|
|
44
|
+
<span
|
|
45
|
+
v-if="parameters"
|
|
46
|
+
class="player-aside-models__params-text"
|
|
47
|
+
>{{ parameters }}</span>
|
|
48
|
+
<span
|
|
49
|
+
v-if="info?.videosCount"
|
|
50
|
+
class="player-aside-models__count"
|
|
51
|
+
>
|
|
52
|
+
<UiIcon
|
|
53
|
+
name="video-camera"
|
|
54
|
+
size="14"
|
|
55
|
+
/>
|
|
56
|
+
{{ n(info.videosCount, 'compact') }}
|
|
57
|
+
</span>
|
|
58
|
+
</span>
|
|
59
|
+
</span>
|
|
60
|
+
</NuxtLink>
|
|
61
|
+
|
|
62
|
+
<div
|
|
63
|
+
v-if="videos?.length"
|
|
64
|
+
class="player-aside-models__grid"
|
|
65
|
+
>
|
|
66
|
+
<CardVideo
|
|
67
|
+
v-for="video in videos"
|
|
68
|
+
:key="`aside-model-video-${video.guid}`"
|
|
69
|
+
:card="video"
|
|
70
|
+
class="player-aside-models__card"
|
|
71
|
+
loading="lazy"
|
|
72
|
+
mini
|
|
73
|
+
/>
|
|
74
|
+
</div>
|
|
75
|
+
</section>
|
|
76
|
+
</template>
|
|
77
|
+
|
|
78
|
+
<script setup lang="ts">
|
|
79
|
+
import { isoCodeToCountryName } from '../../utils/iso-code-to-country-name';
|
|
80
|
+
import type { ICardInfo, IVideoCard } from '../../types';
|
|
81
|
+
|
|
82
|
+
/** Что показываем в строке параметров и в каком порядке — как в макете. */
|
|
83
|
+
const PARAMETER_NAMES = ['guy_type', 'ethnicity', 'age'];
|
|
84
|
+
|
|
85
|
+
const props = defineProps<{
|
|
86
|
+
title: string
|
|
87
|
+
slug: string
|
|
88
|
+
info?: ICardInfo | null
|
|
89
|
+
videos?: IVideoCard[]
|
|
90
|
+
}>();
|
|
91
|
+
|
|
92
|
+
const { t, n, locale } = useI18n();
|
|
93
|
+
const { generateLink } = useGenerateLink();
|
|
94
|
+
|
|
95
|
+
const avatarError = ref(false);
|
|
96
|
+
const avatar = computed(() => avatarError.value ? '' : props.info?.img || '');
|
|
97
|
+
|
|
98
|
+
const parameters = computed(() => PARAMETER_NAMES
|
|
99
|
+
.map((name) => {
|
|
100
|
+
const value = props.info?.parameters?.find(item => item.name === name)?.values?.[0]?.title;
|
|
101
|
+
if (!value) return '';
|
|
102
|
+
|
|
103
|
+
return name === 'age' ? `${value} ${t('yo')}` : value;
|
|
104
|
+
})
|
|
105
|
+
.filter(Boolean)
|
|
106
|
+
.join(' · '));
|
|
107
|
+
</script>
|
|
108
|
+
|
|
109
|
+
<style lang="scss">
|
|
110
|
+
.player-aside-models__head {
|
|
111
|
+
display: grid;
|
|
112
|
+
grid-template-columns: auto minmax(0, 1fr);
|
|
113
|
+
column-gap: 12px;
|
|
114
|
+
align-items: center;
|
|
115
|
+
margin-bottom: 16px;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.player-aside-models__avatar {
|
|
119
|
+
width: 56px;
|
|
120
|
+
height: 56px;
|
|
121
|
+
object-fit: cover;
|
|
122
|
+
object-position: center 10%;
|
|
123
|
+
background-color: $aside-models-avatar-bg;
|
|
124
|
+
border-radius: $radius-full;
|
|
125
|
+
box-shadow: 0 0 0 2px $aside-models-avatar-ring;
|
|
126
|
+
|
|
127
|
+
@media (hover: hover) {
|
|
128
|
+
transition: var(--transition) box-shadow;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
.player-aside-models__avatar.--empty {
|
|
133
|
+
display: grid;
|
|
134
|
+
place-content: center;
|
|
135
|
+
font-weight: 700;
|
|
136
|
+
text-transform: uppercase;
|
|
137
|
+
color: $aside-models-avatar-text;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
@media (hover: hover) {
|
|
141
|
+
.player-aside-models__head:is(:hover, :focus-visible) .player-aside-models__avatar {
|
|
142
|
+
box-shadow: 0 0 0 2px $aside-models-avatar-ring--hover;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.player-aside-models__head:is(:hover, :focus-visible) .player-aside-models__name {
|
|
146
|
+
color: $aside-models-name--hover;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.player-aside-models__info {
|
|
151
|
+
display: grid;
|
|
152
|
+
row-gap: 2px;
|
|
153
|
+
min-width: 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.player-aside-models__name-row {
|
|
157
|
+
display: flex;
|
|
158
|
+
align-items: center;
|
|
159
|
+
column-gap: 6px;
|
|
160
|
+
min-width: 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.player-aside-models__name {
|
|
164
|
+
@include font-base;
|
|
165
|
+
|
|
166
|
+
font-weight: 700;
|
|
167
|
+
color: $aside-models-name;
|
|
168
|
+
white-space: nowrap;
|
|
169
|
+
text-overflow: ellipsis;
|
|
170
|
+
overflow: hidden;
|
|
171
|
+
|
|
172
|
+
@media (hover: hover) {
|
|
173
|
+
transition: var(--transition) color;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.player-aside-models__flag {
|
|
178
|
+
flex-shrink: 0;
|
|
179
|
+
border-radius: 2px;
|
|
180
|
+
object-fit: cover;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.player-aside-models__params {
|
|
184
|
+
@include font-sm;
|
|
185
|
+
|
|
186
|
+
display: flex;
|
|
187
|
+
align-items: center;
|
|
188
|
+
column-gap: 6px;
|
|
189
|
+
min-width: 0;
|
|
190
|
+
color: $aside-models-params;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
.player-aside-models__params-text {
|
|
194
|
+
white-space: nowrap;
|
|
195
|
+
text-overflow: ellipsis;
|
|
196
|
+
overflow: hidden;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/* Разделитель между параметрами и счётчиком — точка, как в макете */
|
|
200
|
+
.player-aside-models__params-text + .player-aside-models__count::before {
|
|
201
|
+
content: '·';
|
|
202
|
+
color: $aside-models-separator;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.player-aside-models__count {
|
|
206
|
+
display: flex;
|
|
207
|
+
align-items: center;
|
|
208
|
+
column-gap: 4px;
|
|
209
|
+
flex-shrink: 0;
|
|
210
|
+
color: $aside-models-count;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.player-aside-models__grid {
|
|
214
|
+
display: grid;
|
|
215
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
216
|
+
column-gap: 12px;
|
|
217
|
+
row-gap: 16px;
|
|
218
|
+
}
|
|
219
|
+
</style>
|
|
@@ -1,14 +1,31 @@
|
|
|
1
|
-
<!-- Боковая колонка
|
|
1
|
+
<!-- Боковая колонка справа от плеера: реклама и блоки моделей ролика.
|
|
2
|
+
|
|
3
|
+
У каждой модели своя шапка со ссылкой и сетка её роликов 2×2 (см. PlayerAsideModels
|
|
4
|
+
и useFetchAsideModels). Реклама, если она включена на сайте, стоит над ними — в
|
|
5
|
+
макете её нет, но формат живёт именно в этой колонке.
|
|
6
|
+
|
|
7
|
+
Если моделей у ролика нет (и рекламы тоже), колонка не пустует: показываем пару
|
|
8
|
+
рекомендаций, как было до макета. -->
|
|
2
9
|
<template>
|
|
3
10
|
<div
|
|
4
11
|
:class="[
|
|
5
12
|
{'_loading': status === 'pending'},
|
|
6
13
|
]"
|
|
7
|
-
class="player-aside-videos _from-
|
|
14
|
+
class="player-aside-videos _from-sd"
|
|
8
15
|
>
|
|
9
16
|
<AdvNtv v-if="featureFlags.AdsNTVEnabled" />
|
|
10
17
|
|
|
11
|
-
<
|
|
18
|
+
<PlayerAsideModels
|
|
19
|
+
v-for="item in models || []"
|
|
20
|
+
:key="`aside-model-${item.slug}`"
|
|
21
|
+
:title="item.title"
|
|
22
|
+
:slug="item.slug"
|
|
23
|
+
:info="item.info"
|
|
24
|
+
:videos="item.videos?.items"
|
|
25
|
+
class="player-aside-videos__model"
|
|
26
|
+
/>
|
|
27
|
+
|
|
28
|
+
<template v-if="!models?.length && !featureFlags.AdsNTVEnabled">
|
|
12
29
|
<CardVideo
|
|
13
30
|
v-for="item in asideVideos?.items?.slice(0, 2)"
|
|
14
31
|
:key="`aside-video-${item.guid}`"
|
|
@@ -20,7 +37,7 @@
|
|
|
20
37
|
</template>
|
|
21
38
|
|
|
22
39
|
<script setup lang="ts">
|
|
23
|
-
import type { IVideoCard, PaginatedResponse } from '../../types';
|
|
40
|
+
import type { ICardInfo, IVideoCard, PaginatedResponse } from '../../types';
|
|
24
41
|
import type { AsyncDataRequestStatus } from '#app';
|
|
25
42
|
|
|
26
43
|
const featureFlags = useRuntimeConfig().public.featureFlags;
|
|
@@ -28,15 +45,23 @@ const featureFlags = useRuntimeConfig().public.featureFlags;
|
|
|
28
45
|
defineProps<{
|
|
29
46
|
asideVideos?: PaginatedResponse<IVideoCard> | null
|
|
30
47
|
status?: AsyncDataRequestStatus
|
|
48
|
+
/** Модели ролика с их карточками и роликами (useFetchAsideModels). */
|
|
49
|
+
models?: Array<{
|
|
50
|
+
title: string
|
|
51
|
+
slug: string
|
|
52
|
+
info: ICardInfo | null
|
|
53
|
+
videos: PaginatedResponse<IVideoCard> | null
|
|
54
|
+
}>
|
|
31
55
|
}>();
|
|
32
56
|
</script>
|
|
33
57
|
|
|
34
58
|
<style lang="scss">
|
|
35
59
|
.player-aside-videos {
|
|
36
|
-
@include from-br(
|
|
60
|
+
@include from-br(sd) {
|
|
37
61
|
display: grid;
|
|
38
|
-
gap:
|
|
62
|
+
gap: 32px;
|
|
39
63
|
grid-template-columns: 1fr;
|
|
64
|
+
width: 288px;
|
|
40
65
|
height: 100%;
|
|
41
66
|
grid-column: 2;
|
|
42
67
|
grid-row: 1;
|
|
@@ -61,15 +61,16 @@ const { nextVideo, goNext } = useNextVideo({
|
|
|
61
61
|
</script>
|
|
62
62
|
|
|
63
63
|
<style lang="scss">
|
|
64
|
+
/* Боковая колонка встаёт с 1024px — так в макете (до этого плеер во всю ширину). */
|
|
64
65
|
.player-main {
|
|
65
|
-
@include to-br(
|
|
66
|
+
@include to-br(sd) {
|
|
66
67
|
display: contents;
|
|
67
68
|
}
|
|
68
69
|
|
|
69
|
-
@include from-br(
|
|
70
|
+
@include from-br(sd) {
|
|
70
71
|
display: grid;
|
|
71
72
|
column-gap: 16px;
|
|
72
|
-
grid-template-columns: 1fr auto;
|
|
73
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
73
74
|
}
|
|
74
75
|
}
|
|
75
76
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { Ref } from 'vue';
|
|
2
|
+
import type { Language } from '../../runtime';
|
|
3
|
+
import { AsyncData, convertString } from '../../runtime';
|
|
4
|
+
import { ModelsApiService } from '../../services/api/models.service';
|
|
5
|
+
import { VideosApiService } from '../../services/api/videos.service';
|
|
6
|
+
import type { ICardInfo, IVideoCard, PaginatedResponse } from '../../types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Сколько моделей ролика показывать в боковой колонке. В макете их две; потолок
|
|
10
|
+
* нужен потому, что каждая модель — это ДВА запроса (инфо + её ролики), и у ролика
|
|
11
|
+
* с длинным составом колонка иначе тянула бы десяток запросов.
|
|
12
|
+
*/
|
|
13
|
+
export const ASIDE_MODELS_LIMIT = 3;
|
|
14
|
+
|
|
15
|
+
/** Сколько роликов модели показывать — в макете сетка 2×2. */
|
|
16
|
+
export const ASIDE_MODEL_VIDEOS = 4;
|
|
17
|
+
|
|
18
|
+
/** Порядок роликов модели — тот же, что на её странице по умолчанию. */
|
|
19
|
+
const ORDER = '-popularity';
|
|
20
|
+
|
|
21
|
+
export interface IAsideModel {
|
|
22
|
+
title: string
|
|
23
|
+
/** Слаг для ссылки на страницу модели. */
|
|
24
|
+
slug: string
|
|
25
|
+
info: Ref<ICardInfo | null>
|
|
26
|
+
videos: Ref<PaginatedResponse<IVideoCard> | null>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Модели ролика для боковой колонки плеера: у каждой — своя карточка (аватар,
|
|
31
|
+
* параметры, счётчик роликов) и первые её ролики.
|
|
32
|
+
*
|
|
33
|
+
* В данных самого ролика у модели есть только `guid` и `title`, поэтому карточку
|
|
34
|
+
* приходится добирать ручками страницы модели. Слаг для ссылки считается из имени
|
|
35
|
+
* так же, как его строят ссылки на модель, а в САМИ ручки уходит не слаг, а имя,
|
|
36
|
+
* прогнанное через `fromSlug` — ровно то, что подставляет страница модели
|
|
37
|
+
* (`useSlug`): бекенд ищет модель по имени, от слага с дефисами он 500-тит.
|
|
38
|
+
*/
|
|
39
|
+
export const useFetchAsideModels = async (
|
|
40
|
+
titles: string[],
|
|
41
|
+
limit = ASIDE_MODELS_LIMIT,
|
|
42
|
+
): Promise<IAsideModel[]> => {
|
|
43
|
+
const lang = useLang() as Language;
|
|
44
|
+
|
|
45
|
+
return await Promise.all(titles.slice(0, limit).map(async (title) => {
|
|
46
|
+
const slug = convertString().toSlug(title);
|
|
47
|
+
const name = convertString().fromSlug(slug);
|
|
48
|
+
const infoKey = `data-${AsyncData.ModelsByName}-aside-${slug}-${lang}`;
|
|
49
|
+
const videosKey = `data-${AsyncData.ModelsVideos}-aside-${slug}-${lang}`;
|
|
50
|
+
|
|
51
|
+
const [{ data: info }, { data: videos }] = await Promise.all([
|
|
52
|
+
useAsyncData<ICardInfo>(
|
|
53
|
+
infoKey,
|
|
54
|
+
() => useApiFetcher<ICardInfo>(infoKey, ModelsApiService.getModelsByName, lang, name)(),
|
|
55
|
+
),
|
|
56
|
+
useAsyncData<PaginatedResponse<IVideoCard>>(
|
|
57
|
+
videosKey,
|
|
58
|
+
() => useApiFetcher<PaginatedResponse<IVideoCard>>(
|
|
59
|
+
videosKey,
|
|
60
|
+
VideosApiService.getListByNicheAndModel,
|
|
61
|
+
{ page: 1, 'per-page': ASIDE_MODEL_VIDEOS },
|
|
62
|
+
{},
|
|
63
|
+
name,
|
|
64
|
+
lang,
|
|
65
|
+
ORDER,
|
|
66
|
+
)(),
|
|
67
|
+
),
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
return { title, slug, info, videos };
|
|
71
|
+
}));
|
|
72
|
+
};
|
|
@@ -16,6 +16,9 @@ type AliveResponse = { statuses: WebcamAliveStatus[], replacements: WebcamReplac
|
|
|
16
16
|
* Опрашиваем только при видимой вкладке: в фоне это бессмысленный трафик, а при
|
|
17
17
|
* возврате на вкладку проверяем сразу, потому что за время «сна» модели могли уйти.
|
|
18
18
|
*
|
|
19
|
+
* Если ручка отвечает 404 (на окружении её просто нет) или трижды подряд падает,
|
|
20
|
+
* проверку выключаем до перезагрузки страницы — нет смысла долбиться каждые 45 секунд.
|
|
21
|
+
*
|
|
19
22
|
* Проверку арминг ставит ОДИН раз на страницу (`useState`-флаг): список общий, а
|
|
20
23
|
* потребителей у него два — блок и врезки.
|
|
21
24
|
*
|
|
@@ -42,9 +45,23 @@ export function useWebcamAlive(cacheKey: string) {
|
|
|
42
45
|
|
|
43
46
|
let timer: ReturnType<typeof setInterval> | null = null;
|
|
44
47
|
let checking = false;
|
|
48
|
+
let stopped = false;
|
|
49
|
+
let failures = 0;
|
|
50
|
+
|
|
51
|
+
/** После скольких подряд неудач бросаем проверять до перезагрузки страницы. */
|
|
52
|
+
const MAX_FAILURES = 3;
|
|
53
|
+
|
|
54
|
+
function stop() {
|
|
55
|
+
stopped = true;
|
|
56
|
+
|
|
57
|
+
if (!timer) return;
|
|
58
|
+
|
|
59
|
+
clearInterval(timer);
|
|
60
|
+
timer = null;
|
|
61
|
+
}
|
|
45
62
|
|
|
46
63
|
async function check() {
|
|
47
|
-
if (checking || document.visibilityState !== 'visible') return;
|
|
64
|
+
if (stopped || checking || document.visibilityState !== 'visible') return;
|
|
48
65
|
|
|
49
66
|
const usernames = models.value.map(model => model.username).filter(Boolean);
|
|
50
67
|
if (!usernames.length) return;
|
|
@@ -58,12 +75,21 @@ export function useWebcamAlive(cacheKey: string) {
|
|
|
58
75
|
body: { usernames },
|
|
59
76
|
});
|
|
60
77
|
|
|
78
|
+
failures = 0;
|
|
79
|
+
|
|
61
80
|
const next = applyWebcamReplacements(models.value, response?.statuses || [], response?.replacements || []);
|
|
62
81
|
// ссылку меняем только когда список действительно другой: иначе лишний
|
|
63
82
|
// ререндер сетки на каждой проверке
|
|
64
83
|
if (next !== models.value) models.value = next;
|
|
65
|
-
} catch {
|
|
66
|
-
|
|
84
|
+
} catch (error) {
|
|
85
|
+
failures += 1;
|
|
86
|
+
|
|
87
|
+
// 404 — ручки на этом окружении нет вовсе (бывает, когда стенд смотрит в
|
|
88
|
+
// API без неё): долбиться каждые 45 секунд незачем. Прочие ошибки могут
|
|
89
|
+
// быть сетевой икотой, поэтому даём три попытки.
|
|
90
|
+
const err = error as { statusCode?: number, status?: number, response?: { status?: number } };
|
|
91
|
+
const status = err?.statusCode ?? err?.status ?? err?.response?.status;
|
|
92
|
+
if (status === 404 || failures >= MAX_FAILURES) stop();
|
|
67
93
|
} finally {
|
|
68
94
|
checking = false;
|
|
69
95
|
}
|
|
@@ -79,7 +105,7 @@ export function useWebcamAlive(cacheKey: string) {
|
|
|
79
105
|
});
|
|
80
106
|
|
|
81
107
|
onScopeDispose(() => {
|
|
82
|
-
|
|
108
|
+
stop();
|
|
83
109
|
document.removeEventListener('visibilitychange', onVisible);
|
|
84
110
|
armed.value = false;
|
|
85
111
|
});
|
package/package.json
CHANGED
package/pages/videos/[slug].vue
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
<PlayerAsideVideos
|
|
15
15
|
:aside-videos="relatedVideos"
|
|
16
16
|
:status="status"
|
|
17
|
+
:models="asideModels"
|
|
17
18
|
class="video__aside"
|
|
18
19
|
/>
|
|
19
20
|
</ViewVideo>
|
|
@@ -50,6 +51,18 @@ const [
|
|
|
50
51
|
useFetchTopModels(ModelsApiService.getTop, 24, AsyncData.TopModelsMore, String(pageName)),
|
|
51
52
|
]);
|
|
52
53
|
|
|
54
|
+
// Модели ролика для боковой колонки: у каждой своя карточка и её ролики. Запрос
|
|
55
|
+
// зависит от ответа по ролику (имена моделей есть только в нём), поэтому идёт
|
|
56
|
+
// вторым заходом, а не в общем Promise.all.
|
|
57
|
+
const asideModelsData = await useFetchAsideModels(videoData.value?.models?.map(item => item.title) || []);
|
|
58
|
+
|
|
59
|
+
const asideModels = computed(() => asideModelsData.map(item => ({
|
|
60
|
+
title: item.title,
|
|
61
|
+
slug: item.slug,
|
|
62
|
+
info: item.info.value,
|
|
63
|
+
videos: item.videos.value,
|
|
64
|
+
})));
|
|
65
|
+
|
|
53
66
|
const { t, locale } = useI18n();
|
|
54
67
|
|
|
55
68
|
const defaultLocale = useRuntimeConfig().public.locales.default;
|
|
@@ -171,11 +184,3 @@ useHead(computed(() => videoJsonLd.value
|
|
|
171
184
|
: {}
|
|
172
185
|
));
|
|
173
186
|
</script>
|
|
174
|
-
|
|
175
|
-
<style lang="scss">
|
|
176
|
-
.video__aside {
|
|
177
|
-
@include from-br(md) {
|
|
178
|
-
width: 288px;
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
</style>
|