itube-specs 0.0.927 → 0.0.929
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 +9 -0
- package/assets/scss/themes/light/_theme.scss +9 -0
- package/components/card/video/index.vue +137 -8
- package/components/player/core.vue +6 -1
- package/package.json +1 -1
- package/runtime/utils/cleaners/__tests__/clean-video-data.test.ts +65 -0
- package/runtime/utils/cleaners/clean-video-data.ts +15 -5
- package/types/video-data.d.ts +6 -0
|
@@ -224,6 +224,15 @@ $video-card-action-active-icon--hover: $color-white;
|
|
|
224
224
|
$video-card-dropdown-border: $color-zinc-800;
|
|
225
225
|
$video-card-dropdown-size--hover: 1;
|
|
226
226
|
|
|
227
|
+
// ─── Video card tags (ряд плашек моделей и поповер «ещё») ───
|
|
228
|
+
$video-card-tags-more-bg: $color-zinc-800-80;
|
|
229
|
+
$video-card-tags-more-bg--hover: $color-zinc-700;
|
|
230
|
+
$video-card-tags-more-text: $color-zinc-300;
|
|
231
|
+
$video-card-tags-more-text--hover: $color-white;
|
|
232
|
+
$video-card-tags-popover-bg: $color-zinc-900;
|
|
233
|
+
$video-card-tags-popover-border: $color-zinc-800;
|
|
234
|
+
$video-card-tags-popover-shadow: 0 10px 15px -3px #{$mono-black-25}, 0 4px 6px -4px #{$mono-black-25};
|
|
235
|
+
|
|
227
236
|
// ─── Video info ───
|
|
228
237
|
$video-info-item-text: $color-zinc-500-70;
|
|
229
238
|
$video-info-handler-text: $color-zinc-400;
|
|
@@ -281,6 +281,15 @@ $video-card-action-active-icon--hover: $color-white;
|
|
|
281
281
|
// ─── Video card dropdown ───
|
|
282
282
|
$video-card-dropdown-border: transparent;
|
|
283
283
|
|
|
284
|
+
// ─── Video card tags (ряд плашек моделей и поповер «ещё») ───
|
|
285
|
+
$video-card-tags-more-bg: $color-gray-300;
|
|
286
|
+
$video-card-tags-more-bg--hover: $color-gray-400;
|
|
287
|
+
$video-card-tags-more-text: $color-gray-700;
|
|
288
|
+
$video-card-tags-more-text--hover: $color-gray-900;
|
|
289
|
+
$video-card-tags-popover-bg: $color-white;
|
|
290
|
+
$video-card-tags-popover-border: $color-gray-300;
|
|
291
|
+
$video-card-tags-popover-shadow: 0 10px 15px -3px #{$mono-black-25}, 0 4px 6px -4px #{$mono-black-25};
|
|
292
|
+
|
|
284
293
|
// ─── Video info ───
|
|
285
294
|
$video-info-item-text: $color-gray-400;
|
|
286
295
|
$video-info-handler-text: $color-gray-400;
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
{'--no-channel': isChannelPage},
|
|
9
9
|
{'--progress': hasProgress},
|
|
10
10
|
{'--favorite': isFavoriteCard},
|
|
11
|
+
{'--tags-open': isTagsOpen},
|
|
11
12
|
]"
|
|
12
13
|
class="card-video"
|
|
13
14
|
>
|
|
@@ -131,11 +132,15 @@
|
|
|
131
132
|
</p>
|
|
132
133
|
<div
|
|
133
134
|
v-if="!mini && !compact && !history && cardTags && cardTags.length > 0"
|
|
135
|
+
ref="tagsRef"
|
|
134
136
|
class="card-video__tags"
|
|
135
137
|
>
|
|
136
|
-
<div
|
|
138
|
+
<div
|
|
139
|
+
class="card-video__tags-wrapper"
|
|
140
|
+
@click="isTagsOpen = false"
|
|
141
|
+
>
|
|
137
142
|
<UiChips
|
|
138
|
-
v-for="(item, index) in cardTags
|
|
143
|
+
v-for="(item, index) in cardTags"
|
|
139
144
|
micro
|
|
140
145
|
:key="`${card.guid}-tag-${index}`"
|
|
141
146
|
:item="item"
|
|
@@ -143,6 +148,14 @@
|
|
|
143
148
|
is-link
|
|
144
149
|
/>
|
|
145
150
|
</div>
|
|
151
|
+
<button
|
|
152
|
+
v-if="hasMoreTags"
|
|
153
|
+
:aria-label="t('show_all_models')"
|
|
154
|
+
:aria-expanded="isTagsOpen"
|
|
155
|
+
class="card-video__tags-more"
|
|
156
|
+
type="button"
|
|
157
|
+
@click="isTagsOpen = !isTagsOpen"
|
|
158
|
+
>+</button>
|
|
146
159
|
</div>
|
|
147
160
|
<CardDropdownTrigger
|
|
148
161
|
:card="card"
|
|
@@ -161,9 +174,13 @@
|
|
|
161
174
|
|
|
162
175
|
<script setup lang="ts">
|
|
163
176
|
import type { IVideoCard } from '../../../types';
|
|
177
|
+
import { onClickOutside } from '@vueuse/core';
|
|
164
178
|
import { convertString, getDuration } from '../../../runtime';
|
|
165
179
|
import { CSS_BREAKPOINTS } from '../../../config/css-breakpoints';
|
|
166
180
|
|
|
181
|
+
/** Потолок плашек в разметке: ряд скроллится, но неограниченный список раздувает DOM всей сетки */
|
|
182
|
+
const TAGS_LIMIT = 8;
|
|
183
|
+
|
|
167
184
|
const featureFlags = useRuntimeConfig().public.featureFlags;
|
|
168
185
|
|
|
169
186
|
const props = defineProps<{
|
|
@@ -280,7 +297,16 @@ const cardTags = computed(() => {
|
|
|
280
297
|
searchRank(a) - searchRank(b) || a.localeCompare(b)
|
|
281
298
|
);
|
|
282
299
|
|
|
283
|
-
return sortModels
|
|
300
|
+
return sortModels.slice(0, TAGS_LIMIT).map(item => convertString().toChips(item, 'models'));
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const tagsRef = ref();
|
|
304
|
+
const isTagsOpen = ref(false);
|
|
305
|
+
|
|
306
|
+
const hasMoreTags = computed(() => cardTags.value.length > 1);
|
|
307
|
+
|
|
308
|
+
onClickOutside(tagsRef, () => {
|
|
309
|
+
isTagsOpen.value = false;
|
|
284
310
|
});
|
|
285
311
|
</script>
|
|
286
312
|
|
|
@@ -288,6 +314,7 @@ const cardTags = computed(() => {
|
|
|
288
314
|
$inset-label: 8px;
|
|
289
315
|
$layout-breakpoint: 250px;
|
|
290
316
|
$progress-height: 4px;
|
|
317
|
+
$tags-fade: 40px;
|
|
291
318
|
|
|
292
319
|
// Колонка под меню карточки фиксирована, а не auto: иначе ширина зависит от того, отрисовано меню
|
|
293
320
|
// или нет, и в момент подмены кнопки заголовок дёргается
|
|
@@ -590,10 +617,36 @@ $dropdown-column: 18px;
|
|
|
590
617
|
aspect-ratio: 16/9;
|
|
591
618
|
}
|
|
592
619
|
|
|
620
|
+
@property --card-video-tags-fade {
|
|
621
|
+
syntax: '<length>';
|
|
622
|
+
inherits: false;
|
|
623
|
+
initial-value: 0;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/* Затемнение гаснет на последних 15% прокрутки — доехал до конца, подсказывать больше нечего */
|
|
627
|
+
@keyframes card-video-tags-fade {
|
|
628
|
+
0%,
|
|
629
|
+
85% {
|
|
630
|
+
--card-video-tags-fade: #{$tags-fade};
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
100% {
|
|
634
|
+
--card-video-tags-fade: 0px;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
@keyframes card-video-tags-more {
|
|
639
|
+
0%,
|
|
640
|
+
100% {
|
|
641
|
+
visibility: visible;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
593
645
|
.card-video__tags {
|
|
594
|
-
|
|
595
|
-
gap: 8px;
|
|
646
|
+
position: relative;
|
|
596
647
|
grid-column: 1;
|
|
648
|
+
min-height: var(--chips-height-micro);
|
|
649
|
+
timeline-scope: --card-video-tags;
|
|
597
650
|
|
|
598
651
|
@include from-cq($layout-breakpoint) {
|
|
599
652
|
grid-column: 2;
|
|
@@ -602,11 +655,87 @@ $dropdown-column: 18px;
|
|
|
602
655
|
}
|
|
603
656
|
|
|
604
657
|
.card-video__tags-wrapper {
|
|
605
|
-
max-height: calc( var(--chips-height-micro) * 2 + 8px);
|
|
606
|
-
overflow: hidden;
|
|
607
658
|
display: flex;
|
|
608
|
-
flex-wrap:
|
|
659
|
+
flex-wrap: nowrap;
|
|
660
|
+
align-items: center;
|
|
609
661
|
gap: 8px;
|
|
662
|
+
min-width: 0;
|
|
663
|
+
overflow-x: auto;
|
|
664
|
+
overscroll-behavior-inline: contain;
|
|
665
|
+
scrollbar-width: none;
|
|
666
|
+
|
|
667
|
+
&::-webkit-scrollbar {
|
|
668
|
+
display: none;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
.card-video__tags-more {
|
|
673
|
+
@include font-xs;
|
|
674
|
+
|
|
675
|
+
position: absolute;
|
|
676
|
+
inset-block: 0;
|
|
677
|
+
right: 0;
|
|
678
|
+
z-index: $z-index-dropdown;
|
|
679
|
+
display: grid;
|
|
680
|
+
place-content: center;
|
|
681
|
+
padding-inline: 8px;
|
|
682
|
+
visibility: hidden;
|
|
683
|
+
color: $video-card-tags-more-text;
|
|
684
|
+
background-color: $video-card-tags-more-bg;
|
|
685
|
+
border-radius: $radius-6;
|
|
686
|
+
|
|
687
|
+
@include hover(background-color, color) {
|
|
688
|
+
background-color: $video-card-tags-more-bg--hover;
|
|
689
|
+
color: $video-card-tags-more-text--hover;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
@include to-br(sm) {
|
|
693
|
+
display: none;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/* Влезли плашки в ряд или нет, решает сам браузер: у непрокручиваемого контейнера scroll-таймлайн
|
|
698
|
+
неактивен, анимации не применяются — ни затемнения, ни кнопки. Без замеров и без JS, то есть
|
|
699
|
+
ещё до гидрации. Где scroll() не поддержан — ряд просто обрезается по краю. */
|
|
700
|
+
@supports (animation-timeline: scroll()) {
|
|
701
|
+
.card-video__tags-wrapper {
|
|
702
|
+
scroll-timeline: --card-video-tags inline;
|
|
703
|
+
animation: card-video-tags-fade linear both;
|
|
704
|
+
animation-timeline: --card-video-tags;
|
|
705
|
+
mask-image: linear-gradient(to right, currentcolor calc(100% - var(--card-video-tags-fade)), transparent);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
.card-video__tags-more {
|
|
709
|
+
animation: card-video-tags-more linear both;
|
|
710
|
+
animation-timeline: --card-video-tags;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/* Раскрытый список — тот же DOM: ряд перестаёт быть скроллом и разворачивается поповером поверх */
|
|
715
|
+
.--tags-open .card-video__tags-wrapper {
|
|
716
|
+
position: absolute;
|
|
717
|
+
top: 0;
|
|
718
|
+
left: 0;
|
|
719
|
+
z-index: $z-index-dropdown;
|
|
720
|
+
flex-wrap: wrap;
|
|
721
|
+
width: max-content;
|
|
722
|
+
max-width: 100%;
|
|
723
|
+
padding: 8px $tags-fade 8px 8px;
|
|
724
|
+
overflow: visible;
|
|
725
|
+
background-color: $video-card-tags-popover-bg;
|
|
726
|
+
border: 1px solid $video-card-tags-popover-border;
|
|
727
|
+
border-radius: $radius-md;
|
|
728
|
+
box-shadow: $video-card-tags-popover-shadow;
|
|
729
|
+
mask-image: none;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
.--tags-open .card-video__tags-more {
|
|
733
|
+
visibility: visible;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/* Карточка — свой контекст наложения из-за container-type, поповер иначе уйдёт под соседнюю */
|
|
737
|
+
.card-video.--tags-open {
|
|
738
|
+
z-index: $z-index-dropdown;
|
|
610
739
|
}
|
|
611
740
|
|
|
612
741
|
.card-video__user {
|
|
@@ -191,7 +191,12 @@ const currentSource = computed<VideoSource>(() => {
|
|
|
191
191
|
thumbnails: props.data.vttUrl,
|
|
192
192
|
// Главы: плеер сам рисует кнопку сцен, подписи и превью из VTT.
|
|
193
193
|
// ITimestamp.duration — момент старта главы (секунды).
|
|
194
|
-
|
|
194
|
+
// `end_duration` бекенд отдаёт не всегда — без него плеер досчитывает конец сам
|
|
195
|
+
chapters: props.data.actions?.map((ts) => ({
|
|
196
|
+
start: ts.duration,
|
|
197
|
+
...(Number(ts.end_duration) > ts.duration ? { end: Number(ts.end_duration) } : {}),
|
|
198
|
+
title: ts.title,
|
|
199
|
+
})),
|
|
195
200
|
// timelines_groups → sceneGroups и hotspots → heatmap (маппинг выше).
|
|
196
201
|
sceneGroups: sceneGroups.value,
|
|
197
202
|
heatmap: heatmap.value,
|
package/package.json
CHANGED
|
@@ -39,6 +39,71 @@ describe('cleanVideoData', () => {
|
|
|
39
39
|
expect(result.thumbNum).toBe(5);
|
|
40
40
|
});
|
|
41
41
|
|
|
42
|
+
it('таймлайны: конец периода из бекендного end_duration', () => {
|
|
43
|
+
// форма как на бою: у первой сцены группы `locations` старта нет вовсе — это ноль
|
|
44
|
+
const raw = {
|
|
45
|
+
timelines_groups: [
|
|
46
|
+
{
|
|
47
|
+
name: 'actions',
|
|
48
|
+
title: 'Scenes',
|
|
49
|
+
actions: [
|
|
50
|
+
{ title: 'oral', duration: 75, end_duration: 140 },
|
|
51
|
+
{ title: 'anal', duration: 264, end_duration: 464 },
|
|
52
|
+
],
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'locations',
|
|
56
|
+
title: 'locations',
|
|
57
|
+
actions: [
|
|
58
|
+
{ title: 'bedroom', end_duration: 200 },
|
|
59
|
+
{ title: 'living_room', duration: 200, end_duration: 479 },
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
expect(cleanVideoData(raw as any).timelinesGroups).toEqual([
|
|
66
|
+
{
|
|
67
|
+
id: 'actions',
|
|
68
|
+
title: 'Scenes',
|
|
69
|
+
scenes: [
|
|
70
|
+
{ start: 75, end: 140, title: 'Oral' },
|
|
71
|
+
{ start: 264, end: 464, title: 'Anal' },
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: 'locations',
|
|
76
|
+
title: 'Locations',
|
|
77
|
+
scenes: [
|
|
78
|
+
{ start: 0, end: 200, title: 'Bedroom' },
|
|
79
|
+
{ start: 200, end: 479, title: 'Living room' },
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('таймлайны: битый или отсутствующий конец периода не пробрасываем', () => {
|
|
86
|
+
const raw = {
|
|
87
|
+
timelines_groups: [
|
|
88
|
+
{
|
|
89
|
+
name: 'actions',
|
|
90
|
+
title: 'Scenes',
|
|
91
|
+
actions: [
|
|
92
|
+
{ title: 'oral', duration: 75 },
|
|
93
|
+
{ title: 'anal', duration: 264, end_duration: 100 },
|
|
94
|
+
{ title: 'kiss', duration: 300, end_duration: 300 },
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
expect(cleanVideoData(raw as any).timelinesGroups?.[0].scenes).toEqual([
|
|
101
|
+
{ start: 75, title: 'Oral' },
|
|
102
|
+
{ start: 264, title: 'Anal' },
|
|
103
|
+
{ start: 300, title: 'Kiss' },
|
|
104
|
+
]);
|
|
105
|
+
});
|
|
106
|
+
|
|
42
107
|
it('пустые данные → дефолты', () => {
|
|
43
108
|
const result = cleanVideoData({} as any);
|
|
44
109
|
expect(result.guid).toBe('');
|
|
@@ -6,7 +6,11 @@ const humanize = (value?: string | null): string => {
|
|
|
6
6
|
return text ? text.charAt(0).toUpperCase() + text.slice(1) : text;
|
|
7
7
|
};
|
|
8
8
|
|
|
9
|
-
// timelines_groups ({ name, actions:[{ duration?, title }] }) →
|
|
9
|
+
// timelines_groups ({ name, actions:[{ duration?, end_duration?, title }] }) →
|
|
10
|
+
// sceneGroups ({ id, title, scenes:[{ start, end?, title }] }). `end` — бекендный конец
|
|
11
|
+
// периода: из него плеер строит «0:00 – 1:54» в меню тайм-кодов (опция
|
|
12
|
+
// `controls.sceneTimes`), а без него досчитывает конец по старту следующей сцены.
|
|
13
|
+
// Мусор не пропускаем: конец раньше старта плееру не нужен.
|
|
10
14
|
const cleanTimelineGroups = (raw: ITimelineGroup[] | null): ISceneGroup[] | null => {
|
|
11
15
|
if (!Array.isArray(raw)) return null;
|
|
12
16
|
|
|
@@ -17,10 +21,16 @@ const cleanTimelineGroups = (raw: ITimelineGroup[] | null): ISceneGroup[] | null
|
|
|
17
21
|
title: humanize(group.title),
|
|
18
22
|
scenes: group.actions
|
|
19
23
|
.filter((action) => action?.title)
|
|
20
|
-
.map((action) =>
|
|
21
|
-
start
|
|
22
|
-
|
|
23
|
-
|
|
24
|
+
.map((action) => {
|
|
25
|
+
const start = Number(action.duration) || 0;
|
|
26
|
+
const end = Number(action.end_duration) || 0;
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
start,
|
|
30
|
+
...(end > start ? { end } : {}),
|
|
31
|
+
title: humanize(action.title),
|
|
32
|
+
};
|
|
33
|
+
}),
|
|
24
34
|
}))
|
|
25
35
|
.filter((group) => group.scenes.length > 0);
|
|
26
36
|
|
package/types/video-data.d.ts
CHANGED
|
@@ -2,12 +2,16 @@ import type { IThumbUrls } from '~/types/thumbs-urls';
|
|
|
2
2
|
|
|
3
3
|
export interface ITimestamp {
|
|
4
4
|
duration: number;
|
|
5
|
+
/** Конец периода в секундах — бекендное `end_duration`, если он его отдал. */
|
|
6
|
+
end_duration?: number;
|
|
5
7
|
title: string;
|
|
6
8
|
}
|
|
7
9
|
|
|
8
10
|
// Сырые группы таймлайна, как приходят с бека.
|
|
9
11
|
export interface ITimelineGroupAction {
|
|
10
12
|
duration?: number;
|
|
13
|
+
/** Конец периода в секундах. Бекенд отдаёт его не у каждой сцены. */
|
|
14
|
+
end_duration?: number;
|
|
11
15
|
title: string;
|
|
12
16
|
}
|
|
13
17
|
|
|
@@ -20,6 +24,8 @@ export interface ITimelineGroup {
|
|
|
20
24
|
// Очищенная форма: id (сырое имя, для иконок), человекочитаемый title, сцены со start.
|
|
21
25
|
export interface ISceneGroupScene {
|
|
22
26
|
start: number;
|
|
27
|
+
/** Конец сцены: плеер строит из него период «0:00 – 1:54» в меню тайм-кодов. */
|
|
28
|
+
end?: number;
|
|
23
29
|
title: string;
|
|
24
30
|
}
|
|
25
31
|
|