itube-specs 0.0.848 → 0.0.850
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/video/index.vue +7 -13
- package/components/card/video/preview.vue +85 -10
- package/composables/use-noindex.ts +25 -9
- package/composables/use-video-preview.ts +45 -0
- package/package.json +1 -1
- package/pages/search/[slug]/index.vue +2 -2
- package/pages/search/[slug]/models.vue +2 -2
- package/pages/search/[slug]/videos.vue +2 -2
- package/pages/tag/[tag].vue +2 -0
|
@@ -11,10 +11,9 @@
|
|
|
11
11
|
<NuxtLink
|
|
12
12
|
:to="link"
|
|
13
13
|
class="card-video__poster-wrapper"
|
|
14
|
-
@mouseenter.passive="
|
|
15
|
-
@
|
|
16
|
-
@
|
|
17
|
-
@touchend.passive="stopVideo"
|
|
14
|
+
@mouseenter.passive="startPreviewByHover(previewId)"
|
|
15
|
+
@mouseleave.passive="stopPreviewByHover(previewId)"
|
|
16
|
+
@touchstart.passive="startPreviewByTouch(previewId)"
|
|
18
17
|
>
|
|
19
18
|
<UiImg
|
|
20
19
|
:src="currentPoster"
|
|
@@ -163,17 +162,12 @@ const { generateLink } = useGenerateLink();
|
|
|
163
162
|
const pageName = useGetPureRouteName();
|
|
164
163
|
const isChannelPage = computed(() => pageName === 'channels-channel');
|
|
165
164
|
|
|
166
|
-
const
|
|
165
|
+
const previewId = useId();
|
|
166
|
+
const { activePreviewId, startPreviewByHover, stopPreviewByHover, startPreviewByTouch } = useVideoPreview();
|
|
167
167
|
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
function playVideo() {
|
|
171
|
-
isPreviewShow.value = true;
|
|
172
|
-
}
|
|
168
|
+
const isPreviewShow = computed(() => activePreviewId.value === previewId);
|
|
173
169
|
|
|
174
|
-
|
|
175
|
-
isPreviewShow.value = false;
|
|
176
|
-
}
|
|
170
|
+
const dropdownMounted = ref(false);
|
|
177
171
|
|
|
178
172
|
const posterError = ref(false);
|
|
179
173
|
const avatarError = ref(false);
|
|
@@ -1,28 +1,103 @@
|
|
|
1
|
-
<!-- Видео-предпросмотр (автоплей) поверх постера карточки видео -->
|
|
1
|
+
<!-- Видео-предпросмотр (автоплей) поверх постера карточки видео + индикатор загрузки -->
|
|
2
2
|
<template>
|
|
3
|
-
<video
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
<span class="card-video-preview">
|
|
4
|
+
<video
|
|
5
|
+
ref="videoRef"
|
|
6
|
+
:src="url"
|
|
7
|
+
:class="{'--playing': isPlaying}"
|
|
8
|
+
class="card-video-preview__video"
|
|
9
|
+
loop
|
|
10
|
+
muted
|
|
11
|
+
autoplay
|
|
12
|
+
playsinline
|
|
13
|
+
@loadedmetadata="updateProgress"
|
|
14
|
+
@progress="updateProgress"
|
|
15
|
+
@playing="onPlaying"
|
|
16
|
+
@waiting="onWaiting"
|
|
17
|
+
@pause="isPlaying = false"
|
|
18
|
+
/>
|
|
19
|
+
<span
|
|
20
|
+
:class="{'--done': isLoaded}"
|
|
21
|
+
:style="{'--preview-progress': isLoaded ? 1 : progress}"
|
|
22
|
+
class="card-video-preview__loader"
|
|
23
|
+
/>
|
|
24
|
+
</span>
|
|
11
25
|
</template>
|
|
12
26
|
|
|
13
27
|
<script setup lang="ts">
|
|
14
28
|
defineProps<{
|
|
15
29
|
url: string
|
|
16
30
|
}>();
|
|
31
|
+
|
|
32
|
+
const videoRef = ref<HTMLVideoElement | null>(null);
|
|
33
|
+
const isPlaying = ref(false);
|
|
34
|
+
const isLoaded = ref(false);
|
|
35
|
+
const progress = ref(0);
|
|
36
|
+
|
|
37
|
+
/** Доля скачанного файла превью: правый край последнего буферизованного отрезка к общей длительности. */
|
|
38
|
+
function updateProgress() {
|
|
39
|
+
const video = videoRef.value;
|
|
40
|
+
if (!video || !video.buffered.length || !Number.isFinite(video.duration)) return;
|
|
41
|
+
|
|
42
|
+
progress.value = video.buffered.end(video.buffered.length - 1) / video.duration;
|
|
43
|
+
if (progress.value >= 1) isLoaded.value = true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function onPlaying() {
|
|
47
|
+
isPlaying.value = true;
|
|
48
|
+
isLoaded.value = true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Буферизация на середине: возвращаем постер и полосу, она продолжит с реально скачанного места. */
|
|
52
|
+
function onWaiting() {
|
|
53
|
+
isPlaying.value = false;
|
|
54
|
+
isLoaded.value = false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
onMounted(() => {
|
|
58
|
+
const video = videoRef.value;
|
|
59
|
+
if (!video) return;
|
|
60
|
+
|
|
61
|
+
video.muted = true;
|
|
62
|
+
video.play().catch(() => {
|
|
63
|
+
isPlaying.value = false;
|
|
64
|
+
});
|
|
65
|
+
});
|
|
17
66
|
</script>
|
|
18
67
|
|
|
19
68
|
<style lang="scss">
|
|
69
|
+
$loader-height: 4px;
|
|
70
|
+
|
|
20
71
|
.card-video-preview {
|
|
21
72
|
position: absolute;
|
|
22
73
|
inset: 0;
|
|
74
|
+
z-index: $z-index-plus;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.card-video-preview__video {
|
|
23
78
|
width: 100%;
|
|
24
79
|
height: 100%;
|
|
25
|
-
z-index: $z-index-plus;
|
|
26
80
|
object-fit: cover;
|
|
81
|
+
opacity: 0;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* Пока автоплей не стартовал, браузер рисует поверх видео нативную кнопку плей — держим элемент невидимым */
|
|
85
|
+
.card-video-preview__video.--playing {
|
|
86
|
+
opacity: 1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.card-video-preview__loader {
|
|
90
|
+
position: absolute;
|
|
91
|
+
inset: 0 0 auto;
|
|
92
|
+
height: $loader-height;
|
|
93
|
+
background: $video-card-preview-loader-bg;
|
|
94
|
+
transform: scaleX(var(--preview-progress, 0));
|
|
95
|
+
transform-origin: left center;
|
|
96
|
+
transition: var(--transition) transform, var(--transition) var(--default-transition-duration) opacity;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* Догрузилось или пошло воспроизведение — полоса доезжает до конца и только потом гаснет */
|
|
100
|
+
.card-video-preview__loader.--done {
|
|
101
|
+
opacity: 0;
|
|
27
102
|
}
|
|
28
103
|
</style>
|
|
@@ -1,13 +1,29 @@
|
|
|
1
|
+
/** Минимум элементов в листинге, при котором страницу ещё имеет смысл индексировать. */
|
|
2
|
+
export const MIN_INDEXABLE_ITEMS = 5;
|
|
3
|
+
|
|
1
4
|
/**
|
|
2
5
|
* Помечает страницу как noindex для поисковиков (ссылки при этом проходятся — follow).
|
|
3
|
-
* Для приватных/служебных
|
|
6
|
+
* Для приватных/служебных страниц, результатов внутреннего поиска и «тонких» листингов.
|
|
7
|
+
* Условие реактивное: пока оно false, тег не выпускается и hreflang-кластер остаётся.
|
|
8
|
+
*/
|
|
9
|
+
export function useNoindex(when: MaybeRefOrGetter<boolean> = true) {
|
|
10
|
+
const isNoindex = computed(() => toValue(when));
|
|
11
|
+
|
|
12
|
+
useHead(() => ({
|
|
13
|
+
meta: isNoindex.value
|
|
14
|
+
? [{ name: 'robots', content: 'noindex, follow' }]
|
|
15
|
+
: [],
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
useSetSeoOverride(() => ({ noindex: isNoindex.value }));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Закрывает от индексации «тонкий» листинг — страницу, где основных элементов
|
|
23
|
+
* (роликов, а на листинге моделей — моделей) меньше порога. Считается по фактически
|
|
24
|
+
* выданным на этом URL элементам, поэтому хвост пагинации и пустые выборки
|
|
25
|
+
* по фильтрам тоже уходят в noindex.
|
|
4
26
|
*/
|
|
5
|
-
export function
|
|
6
|
-
|
|
7
|
-
meta: [
|
|
8
|
-
{ name: 'robots', content: 'noindex, follow' },
|
|
9
|
-
],
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
useSetSeoOverride(() => ({ noindex: true }));
|
|
27
|
+
export function useNoindexThinListing(count: MaybeRefOrGetter<number | undefined>) {
|
|
28
|
+
useNoindex(() => (toValue(count) ?? 0) < MIN_INDEXABLE_ITEMS);
|
|
13
29
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
let isTouchUsed = false;
|
|
2
|
+
let isOutsideTouchWatched = false;
|
|
3
|
+
|
|
4
|
+
/** Хранит id карточки с активным превью: играет только одно, запуск нового гасит предыдущее. */
|
|
5
|
+
export const useVideoPreview = () => {
|
|
6
|
+
const activePreviewId = useState<string | null>('active-video-preview', () => null);
|
|
7
|
+
|
|
8
|
+
const startPreviewByHover = (id: string) => {
|
|
9
|
+
if (isTouchUsed) return;
|
|
10
|
+
activePreviewId.value = id;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/** На тач-устройствах превью живёт до запуска следующего, поэтому гасим только по уходу мыши. */
|
|
14
|
+
const stopPreviewByHover = (id: string) => {
|
|
15
|
+
if (isTouchUsed) return;
|
|
16
|
+
if (activePreviewId.value === id) activePreviewId.value = null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Гасит превью на любом касании страницы. Слушатель на capture-фазе, поэтому срабатывает раньше обработчика
|
|
21
|
+
* карточки: тап по другой карточке сначала гасит текущее превью, а потом запускает своё, тап по пустому месту — только гасит.
|
|
22
|
+
*/
|
|
23
|
+
const watchOutsideTouch = () => {
|
|
24
|
+
if (isOutsideTouchWatched) return;
|
|
25
|
+
isOutsideTouchWatched = true;
|
|
26
|
+
|
|
27
|
+
document.addEventListener('touchstart', () => {
|
|
28
|
+
activePreviewId.value = null;
|
|
29
|
+
}, { capture: true, passive: true });
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Тач-устройства дублируют тап синтетическими mouse-событиями — с первого касания ховерный путь глушим. */
|
|
33
|
+
const startPreviewByTouch = (id: string) => {
|
|
34
|
+
isTouchUsed = true;
|
|
35
|
+
watchOutsideTouch();
|
|
36
|
+
activePreviewId.value = id;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
activePreviewId,
|
|
41
|
+
startPreviewByHover,
|
|
42
|
+
stopPreviewByHover,
|
|
43
|
+
startPreviewByTouch,
|
|
44
|
+
};
|
|
45
|
+
};
|
package/package.json
CHANGED
|
@@ -127,8 +127,6 @@ const featureFlags = useRuntimeConfig().public.featureFlags;
|
|
|
127
127
|
|
|
128
128
|
const slug = useSlug();
|
|
129
129
|
|
|
130
|
-
useNoindex();
|
|
131
|
-
|
|
132
130
|
const [
|
|
133
131
|
{ data: modelsData },
|
|
134
132
|
{ data: categoriesData },
|
|
@@ -140,6 +138,8 @@ const [
|
|
|
140
138
|
useFetchVideosSearchByNiche(VideosApiService.getSearchByNiche),
|
|
141
139
|
useFetchRelatedSearch(SearchApiService.getPhrases),
|
|
142
140
|
]);
|
|
141
|
+
|
|
142
|
+
useNoindexThinListing(() => videosData.value?.items?.length);
|
|
143
143
|
const { t } = useI18n();
|
|
144
144
|
const { generateLink } = useGenerateLink();
|
|
145
145
|
|
|
@@ -26,8 +26,6 @@ import { sortModels } from '../../../lib';
|
|
|
26
26
|
|
|
27
27
|
const slug = useSlug();
|
|
28
28
|
|
|
29
|
-
useNoindex();
|
|
30
|
-
|
|
31
29
|
const { t } = useI18n();
|
|
32
30
|
const pagePureName = useGetPureRouteName();
|
|
33
31
|
|
|
@@ -37,6 +35,8 @@ const [
|
|
|
37
35
|
useFetchModelsByPhrases(ModelsApiService.getModelsSearchFiltered(slug.value)),
|
|
38
36
|
]);
|
|
39
37
|
|
|
38
|
+
useNoindexThinListing(() => modelsData.value?.items?.length);
|
|
39
|
+
|
|
40
40
|
const metaTitle = computed(() => convertString().toCapitalize(slug.value));
|
|
41
41
|
|
|
42
42
|
const { meta, h1 } = useMeta(t, pagePureName, useAppConfig().brandName, sortModels, metaTitle);
|
|
@@ -28,8 +28,6 @@ import { sortItemsDefault } from '../../../lib';
|
|
|
28
28
|
|
|
29
29
|
const slug = useSlug();
|
|
30
30
|
|
|
31
|
-
useNoindex();
|
|
32
|
-
|
|
33
31
|
const { t } = useI18n();
|
|
34
32
|
|
|
35
33
|
const [
|
|
@@ -38,6 +36,8 @@ const [
|
|
|
38
36
|
useFetchVideos(VideosApiService.getSearchByNicheFiltered(slug.value)),
|
|
39
37
|
]);
|
|
40
38
|
|
|
39
|
+
useNoindexThinListing(() => videosData.value?.items?.length);
|
|
40
|
+
|
|
41
41
|
const pagePureName = useGetPureRouteName();
|
|
42
42
|
const metaTitle = computed(() => convertString().toCapitalize(slug.value));
|
|
43
43
|
|
package/pages/tag/[tag].vue
CHANGED