itube-specs 0.0.1073 → 0.0.1075

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/SITE_UPDATES.md CHANGED
@@ -11,6 +11,36 @@
11
11
  коммиты до бампа); `npm run patch` сам проставит ей номер версии. Формат пункта: что сделать и где,
12
12
  кому именно (всем / сайтам с N), что будет, если не сделать.
13
13
 
14
+ ## 0.0.1075 — 2026-11-06
15
+
16
+ - **Сайтам со своей темой в `assets/scss/vars/_theme.scss` (hdsex-remaster), срочно:** без нового токена сборка
17
+ падает на `Undefined variable`. Добавить `$layout-age-popup-text` (тёмная — `$color-zinc-400`, светлая —
18
+ `$color-gray-600`), `$ui-img-gate-badge-text: $color-white;`, `$ui-img-gate-badge-bg: $color-black-42;`,
19
+ `$ui-img-gate-badge-border: $mono-white-90;`. Шесть `$layout-age-banner-*` и `$ui-img-gate-text` больше не
20
+ нужны — удалить.
21
+ - **Сайтам с `FeatureAgeVerificationEnabled` — к сведению:** заглушка превью — размытая абстрактная картинка слоя
22
+ (`public/img/age-gate.webp`, без реального кадра) со значком «18+» в кружке; в аватарах меньше 40px — без значка.
23
+ - **Сайтам с `FeatureAgeVerificationEnabled` — к сведению:** плашка под шапкой (`LayoutAgeBanner`) заменена попапом
24
+ `LayoutAgePopup` — предупреждение о верификации в регионе, кнопки «Verify age» и «Close»; после закрытия
25
+ показывается снова на следующей странице. Заглушки превью и блокировка плеера — как были. Тексты `age_gate.title`
26
+ и `age_gate.text` переписаны под попап.
27
+
28
+ ## 0.0.1074 — 2026-11-05
29
+
30
+ - **Сайтам со своей темой в `assets/scss/vars/_theme.scss` (hdsex-remaster), срочно:** без новых токенов сборка
31
+ падает на `Undefined variable`. Добавить `$player-scenes-text`, `$player-scenes-text--current`, `$player-scenes-bg`,
32
+ `$player-scenes-bg--hover`, `$player-scenes-bg--current`, `$player-scenes-border`, `$player-scenes-border--current`,
33
+ `$player-scenes-time`, `$player-scenes-time-bg`, `$player-scenes-time-bg--current`,
34
+ `$player-scenes-time-border--current`, `$player-scenes-mask` — значения из `themes/dark/_theme.scss` слоя.
35
+ - **Всем — к сведению:** под плеером ряд сцен ролика (`PlayerScenes`, макет v0-dark-eta /video/2): «тайм-код |
36
+ название», текущая подсвечена, клик перематывает (до загрузки плеера — стартует его и перематывает). Сцены — первый
37
+ тип из `timelines_groups` (дальше — выбранный в плеере), без групп — `actions`. Нет сцен у ролика — ряда нет. Новый
38
+ общий ключ `scenes`. Делать ничего не надо.
39
+ - **Всем — к сведению:** категории в строке чипсов над плеером теперь берутся из группы `top_categories` ответа
40
+ видео v2 (`IVideoData.topCategories`) как есть, без сверки с `categories`; в блок под роликом эта группа не
41
+ попадает. Пометка `categories[].isTop`, `videoDataMarkTopCategories` и запрос BFF к `/v1/categories/top` убраны.
42
+ Ролик без `top_categories` — категорий над плеером нет. Делать ничего не надо, если сайт не читал `isTop` сам.
43
+
14
44
  ## 0.0.1073 — 2026-11-03
15
45
 
16
46
  - **Всем — к сведению:** гость больше не видит заглушку «Sign in to upload» на `/upload`: клик по Upload (шапка,
@@ -33,14 +33,11 @@ $layout-header-border: $color-zinc-800-30;
33
33
  $layout-nav-border: $color-zinc-800-50;
34
34
  $layout-skip-link-bg: $color-zinc-800-60;
35
35
  $layout-skip-link-text: $color-white;
36
- $layout-age-banner-bg: $color-emerald-600;
37
- $layout-age-banner-title: $color-white;
38
- $layout-age-banner-text: $color-white-90;
39
- $layout-age-banner-btn-bg: $color-white;
40
- $layout-age-banner-btn-bg--hover: $color-white-90;
41
- $layout-age-banner-btn-text: $color-green-900;
36
+ $layout-age-popup-text: $color-zinc-400;
42
37
  $ui-img-gate-bg: $color-zinc-800;
43
- $ui-img-gate-text: $color-zinc-500;
38
+ $ui-img-gate-badge-text: $color-white;
39
+ $ui-img-gate-badge-bg: $color-black-42;
40
+ $ui-img-gate-badge-border: $mono-white-90;
44
41
 
45
42
  // ─── Page footer links (mixin) ───
46
43
  $page-footer-links-border: $color-zinc-800;
@@ -783,6 +780,20 @@ $player-menu-option-bg--hover: $mono-white-5; // hover:bg-white/5
783
780
  $player-menu-toggle-bg--active: $color-indigo-500; // switch on: bg-indigo-500
784
781
  $player-center-label-shadow: $color-black-50; // мета в центре бара поверх видео
785
782
 
783
+ // ─── Player scenes (сцены под плеером) ───
784
+ $player-scenes-text: $color-zinc-300;
785
+ $player-scenes-text--current: $color-white;
786
+ $player-scenes-bg: transparent;
787
+ $player-scenes-bg--hover: $color-zinc-800;
788
+ $player-scenes-bg--current: $color-zinc-800;
789
+ $player-scenes-border: $color-zinc-800;
790
+ $player-scenes-border--current: $color-zinc-500;
791
+ $player-scenes-time: $color-zinc-400;
792
+ $player-scenes-time-bg: $color-zinc-900;
793
+ $player-scenes-time-bg--current: $color-zinc-700;
794
+ $player-scenes-time-border--current: $color-zinc-600;
795
+ $player-scenes-mask: $color-black; // непрозрачная часть маски затухания ленты
796
+
786
797
  // accordion (scene types)
787
798
  $player-menu-acc-border: $color-zinc-800-60; // border-zinc-800/60
788
799
  $player-menu-acc-head-text: $color-zinc-200; // text-zinc-200
@@ -44,14 +44,11 @@ $layout-header-border: transparent;
44
44
  $layout-nav-border: $color-slate-750;
45
45
  $layout-skip-link-bg: $color-slate-750;
46
46
  $layout-skip-link-text: $color-white;
47
- $layout-age-banner-bg: $color-emerald-600;
48
- $layout-age-banner-title: $color-white;
49
- $layout-age-banner-text: $color-white-90;
50
- $layout-age-banner-btn-bg: $color-white;
51
- $layout-age-banner-btn-bg--hover: $color-white-90;
52
- $layout-age-banner-btn-text: $color-green-900;
47
+ $layout-age-popup-text: $color-gray-600;
53
48
  $ui-img-gate-bg: $color-gray-200;
54
- $ui-img-gate-text: $color-gray-500;
49
+ $ui-img-gate-badge-text: $color-white;
50
+ $ui-img-gate-badge-bg: $color-black-42;
51
+ $ui-img-gate-badge-border: $mono-white-90;
55
52
 
56
53
  // ─── Page footer links (mixin) ───
57
54
  $page-footer-links-border: transparent;
@@ -835,6 +832,20 @@ $player-menu-option-bg--hover: $mono-white-5; // hover:bg-white/5
835
832
  $player-menu-toggle-bg--active: $color-indigo-500; // switch on: bg-indigo-500
836
833
  $player-center-label-shadow: $color-black-50; // мета в центре бара поверх видео
837
834
 
835
+ // ─── Player scenes (сцены под плеером) ───
836
+ $player-scenes-text: $color-gray-700;
837
+ $player-scenes-text--current: $color-gray-900;
838
+ $player-scenes-bg: transparent;
839
+ $player-scenes-bg--hover: $color-gray-100;
840
+ $player-scenes-bg--current: $color-gray-200;
841
+ $player-scenes-border: $color-gray-200;
842
+ $player-scenes-border--current: $color-gray-400;
843
+ $player-scenes-time: $color-gray-500;
844
+ $player-scenes-time-bg: $color-gray-100;
845
+ $player-scenes-time-bg--current: $color-gray-300;
846
+ $player-scenes-time-border--current: $color-gray-400;
847
+ $player-scenes-mask: $color-black; // непрозрачная часть маски затухания ленты
848
+
838
849
  // accordion (scene types)
839
850
  $player-menu-acc-border: $color-slate-700; // border-zinc-800/60
840
851
  $player-menu-acc-head-text: $color-gray-200; // text-zinc-200
@@ -0,0 +1,81 @@
1
+ <!-- Попап на каждой странице, пока возрастная верификация требуется и не пройдена (useAgeGate) -->
2
+ <template>
3
+ <transition mode="out-in">
4
+ <UiPopup
5
+ v-if="isOpen"
6
+ v-model="isOpen"
7
+ sheet
8
+ >
9
+ <template #title>{{ t('age_gate.title') }}</template>
10
+
11
+ <p class="layout-age-popup__text">{{ t('age_gate.text') }}</p>
12
+
13
+ <template #footer>
14
+ <div class="layout-age-popup__buttons">
15
+ <UiBtn
16
+ wide
17
+ size="l"
18
+ theme="secondary"
19
+ @click="isOpen = false"
20
+ >{{ t('close') }}</UiBtn>
21
+ <UiBtn
22
+ :disabled="isPending"
23
+ wide
24
+ size="l"
25
+ theme="primary"
26
+ @click="onVerify"
27
+ >{{ t('age_gate.button') }}</UiBtn>
28
+ </div>
29
+ </template>
30
+ </UiPopup>
31
+ </transition>
32
+ </template>
33
+
34
+ <script setup lang="ts">
35
+ const { t } = useI18n();
36
+ const route = useRoute();
37
+ const { startVerification } = useAgeGate();
38
+
39
+ const isOpen = ref(true);
40
+ const isPending = ref(false);
41
+
42
+ watch(() => route.path, () => {
43
+ isOpen.value = true;
44
+ });
45
+
46
+ const onVerify = async () => {
47
+ isPending.value = true;
48
+ try {
49
+ await startVerification(route.fullPath);
50
+ } finally {
51
+ isPending.value = false;
52
+ }
53
+ };
54
+ </script>
55
+
56
+ <style lang="scss">
57
+ .layout-age-popup__text {
58
+ @include font-sm;
59
+
60
+ max-width: 400px;
61
+ padding-inline: 8px;
62
+ padding-block: 8px;
63
+ color: $layout-age-popup-text;
64
+
65
+ @include from-br(sm) {
66
+ @include font-base;
67
+
68
+ padding-inline: 16px;
69
+ padding-block: 16px;
70
+ }
71
+ }
72
+
73
+ .layout-age-popup__buttons {
74
+ display: grid;
75
+ gap: 8px;
76
+
77
+ @include from-br(sm) {
78
+ grid-template-columns: 1fr 1fr;
79
+ }
80
+ }
81
+ </style>
@@ -18,8 +18,10 @@
18
18
  @advancerequest="onAdvanceRequest"
19
19
  @sourcechange="onSourceChange"
20
20
  @menurequest="onMenuRequest"
21
+ @chapterchange="onChapterChange"
22
+ @scenetypechange="onSceneTypeChange"
21
23
  @pause="onPause"
22
- @ready="siteHooks.onReady?.()"
24
+ @ready="onReady"
23
25
  @blockedclick="startVerification(route.fullPath)"
24
26
  >
25
27
  <template
@@ -34,6 +36,13 @@
34
36
  </template>
35
37
  </ITubePlayer>
36
38
 
39
+ <PlayerScenes
40
+ :scenes="scenes"
41
+ :current="currentSceneStart"
42
+ class="player-core__scenes"
43
+ @select="onSceneSelect"
44
+ />
45
+
37
46
  <!-- Внешнее меню плеера в нашем ui-popup (только мобила — menus: 'external-mobile'). -->
38
47
  <PlayerMenu
39
48
  v-model:open="menuOpen"
@@ -44,7 +53,7 @@
44
53
  <script setup lang="ts">
45
54
  import { ITubePlayer } from 'itube-modern-player/vue';
46
55
 
47
- import type { Player, PlayerOptions, VideoSource, LocaleCode, AdsOptions, PreviewMetaItem, SceneGroup, MenuRequestPayload } from 'itube-modern-player';
56
+ import type { Player, PlayerOptions, VideoSource, LocaleCode, AdsOptions, PreviewMetaItem, SceneGroup, MenuRequestPayload, Chapter } from 'itube-modern-player';
48
57
  import { ThumbSize } from '../../runtime';
49
58
  import type { IVideoData, IVideoCard } from '../../types';
50
59
  import playIcon from '../../assets/icons/player/play.svg?raw';
@@ -107,6 +116,57 @@ function onMenuRequest(request: MenuRequestPayload): void {
107
116
  menuOpen.value = true;
108
117
  }
109
118
 
119
+ // Сцены под плеером (PlayerScenes): до загрузки ядра — первый тип сцен, как в заглушке плеера (или главы), дальше
120
+ // активный тип и текущая сцена приходят событиями. Клик до загрузки стартует плеер через кнопку play заглушки и
121
+ // перематывает на ready.
122
+ const initialScenes = (): Chapter[] =>
123
+ currentSource.value.sceneGroups?.[0]?.scenes
124
+ ?? (Array.isArray(currentSource.value.chapters) ? currentSource.value.chapters : []);
125
+
126
+ const scenes = shallowRef<Chapter[]>([]);
127
+ const currentSceneStart = ref<number | null>(null);
128
+ let pendingSeek: number | null = null;
129
+
130
+ function onChapterChange({ chapter }: { chapter: Chapter | null }): void {
131
+ currentSceneStart.value = chapter?.start ?? null;
132
+ }
133
+
134
+ function onSceneTypeChange({ group }: { group: SceneGroup | null }): void {
135
+ scenes.value = group?.scenes ?? initialScenes();
136
+ }
137
+
138
+ // На ready у HLS ещё нет метаданных и перемотка теряется — ждём первый timeupdate с известной длительностью.
139
+ function onReady(): void {
140
+ siteHooks.onReady?.();
141
+ const player = playerRef.value?.player;
142
+ if (pendingSeek === null || !player) return;
143
+
144
+ const stop = player.on('timeupdate', ({ duration }) => {
145
+ if (!(duration > 0) || pendingSeek === null) return;
146
+
147
+ player.seek(pendingSeek);
148
+ pendingSeek = null;
149
+ stop();
150
+ });
151
+ }
152
+
153
+ function onSceneSelect(start: number): void {
154
+ if (isAgeGated.value) {
155
+ startVerification(route.fullPath);
156
+ return;
157
+ }
158
+
159
+ const player = playerRef.value?.player;
160
+ if (player) {
161
+ player.seek(start);
162
+ player.play();
163
+ return;
164
+ }
165
+
166
+ pendingSeek = start;
167
+ (playerRef.value?.$el as HTMLElement | undefined)?.querySelector<HTMLElement>('.imp-ph-play, .imp-ph-btn--play')?.click();
168
+ }
169
+
110
170
  // Плеер эмитит advancerequest 'ended' только при включённом тумблере Autoplay, а на
111
171
  // кнопку next — 'next'. Значит play после свапа нужен ТОЛЬКО когда переход инициировали
112
172
  // мы (next / автопереход), а не когда source пересобрался по другой причине (сменилось
@@ -278,8 +338,11 @@ const buildSource = (): VideoSource | VideoSource[] =>
278
338
  // следующего (префетч) дёргала бы player.load() и перезапускала текущий ролик + ломала
279
339
  // автоплей (лишние sourcechange). Один переход = один reload.
280
340
  const source = shallowRef<VideoSource | VideoSource[]>(buildSource());
341
+ scenes.value = initialScenes();
281
342
  watch(() => props.data.videoUrl, () => {
282
343
  source.value = buildSource();
344
+ scenes.value = initialScenes();
345
+ currentSceneStart.value = null;
283
346
  });
284
347
 
285
348
  /**
@@ -370,6 +433,10 @@ async function loadAdConfig(): Promise<AdsOptions | undefined> {
370
433
  }
371
434
  }
372
435
 
436
+ .player-core__scenes {
437
+ margin-top: 12px;
438
+ }
439
+
373
440
  // Мета ролика в центре бара: по макету белая с тенью и средней жирностью,
374
441
  // у плеера по умолчанию — приглушённая и без тени.
375
442
  .player-core .imp-controls__chapter {
@@ -0,0 +1,138 @@
1
+ <!-- Сцены ролика под плеером (внешний контрол, как PlayerMenu): «тайм-код | название», текущая подсвечена.
2
+ Данные и перемотку держит PlayerCore — сцены активного типа и текущую сцену он берёт из событий плеера.
3
+ До 960px — одна строка с прокруткой и затуханием у правого края, шире — перенос строк. -->
4
+ <template>
5
+ <nav
6
+ v-if="scenes.length"
7
+ :aria-label="t('scenes')"
8
+ class="player-scenes"
9
+ >
10
+ <ol class="player-scenes__list">
11
+ <li
12
+ v-for="scene in scenes"
13
+ :key="scene.start"
14
+ class="player-scenes__item"
15
+ >
16
+ <button
17
+ :class="{ '--current': scene.start === current }"
18
+ :aria-current="scene.start === current || undefined"
19
+ :aria-label="`${scene.title}, ${formatSceneTime(scene.start)}`"
20
+ class="player-scenes__btn"
21
+ type="button"
22
+ @click="emit('select', scene.start)"
23
+ >
24
+ <span class="player-scenes__time">{{ formatSceneTime(scene.start) }}</span>
25
+ <span class="player-scenes__title">{{ scene.title }}</span>
26
+ </button>
27
+ </li>
28
+ </ol>
29
+ </nav>
30
+ </template>
31
+
32
+ <script setup lang="ts">
33
+ import type { Chapter } from 'itube-modern-player';
34
+
35
+ defineProps<{
36
+ scenes: Chapter[]
37
+ /** `start` текущей сцены, `null` — ни одна не идёт. */
38
+ current: number | null
39
+ }>();
40
+
41
+ const emit = defineEmits<{
42
+ (eventName: 'select', start: number): void
43
+ }>();
44
+
45
+ const { t } = useI18n();
46
+
47
+ /** Как в контрол-баре плеера: минуты без ведущего нуля — `0:55`, `1:02:03`. */
48
+ function formatSceneTime(seconds: number): string {
49
+ const total = Math.max(0, Math.floor(seconds));
50
+ const h = Math.floor(total / 3600);
51
+ const m = Math.floor((total % 3600) / 60);
52
+ const s = String(total % 60).padStart(2, '0');
53
+
54
+ return h ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`;
55
+ }
56
+ </script>
57
+
58
+ <style lang="scss">
59
+ .player-scenes__list {
60
+ display: flex;
61
+ flex-wrap: nowrap;
62
+ gap: 8px;
63
+ overflow-x: auto;
64
+ margin-inline: calc(-1 * var(--container-padding-inline));
65
+ padding-inline: var(--container-padding-inline) 40px;
66
+ scrollbar-width: none;
67
+ mask-image: linear-gradient(to right, $player-scenes-mask calc(100% - 40px), transparent);
68
+
69
+ &::-webkit-scrollbar {
70
+ display: none;
71
+ }
72
+
73
+ @include from-br(sm) {
74
+ flex-wrap: wrap;
75
+ overflow-x: visible;
76
+ margin-inline: 0;
77
+ padding-inline: 0;
78
+ mask-image: none;
79
+ }
80
+ }
81
+
82
+ .player-scenes__item {
83
+ flex-shrink: 0;
84
+ }
85
+
86
+ .player-scenes__btn {
87
+ @include font-xs;
88
+
89
+ display: flex;
90
+ align-items: center;
91
+ height: 36px;
92
+ overflow: hidden;
93
+ white-space: nowrap;
94
+ color: $player-scenes-text;
95
+ background-color: $player-scenes-bg;
96
+ border: 1px solid $player-scenes-border;
97
+ border-radius: $radius-md;
98
+
99
+ @include hover(background-color, color) {
100
+ color: $player-scenes-text--current;
101
+ background-color: $player-scenes-bg--hover;
102
+ }
103
+
104
+ @include from-br(sm) {
105
+ @include font-sm;
106
+ }
107
+
108
+ &.--current {
109
+ color: $player-scenes-text--current;
110
+ background-color: $player-scenes-bg--current;
111
+ border-color: $player-scenes-border--current;
112
+ }
113
+ }
114
+
115
+ .player-scenes__time {
116
+ @include font-xs;
117
+
118
+ display: flex;
119
+ align-items: center;
120
+ height: 100%;
121
+ padding-inline: 10px;
122
+ font-weight: 500;
123
+ font-variant-numeric: tabular-nums;
124
+ color: $player-scenes-time;
125
+ background-color: $player-scenes-time-bg;
126
+ border-right: 1px solid $player-scenes-border;
127
+ }
128
+
129
+ .player-scenes__btn.--current .player-scenes__time {
130
+ color: $player-scenes-text--current;
131
+ background-color: $player-scenes-time-bg--current;
132
+ border-right-color: $player-scenes-time-border--current;
133
+ }
134
+
135
+ .player-scenes__title {
136
+ padding-inline: 12px;
137
+ }
138
+ </style>
@@ -125,29 +125,53 @@ const emit = defineEmits<{
125
125
  .ui-img-gate {
126
126
  container-type: inline-size;
127
127
  background-color: $ui-img-gate-bg;
128
+ background-image: url('/img/age-gate.webp');
129
+ background-position: center;
130
+ background-size: cover;
128
131
  }
129
132
 
130
133
  .ui-img-gate__label {
131
- @include font-lg;
134
+ @include font-base;
132
135
 
133
136
  position: absolute;
134
- inset: 0;
137
+ top: 50%;
138
+ left: 50%;
135
139
  display: flex;
136
140
  align-items: center;
137
141
  justify-content: center;
138
- font-weight: 700;
139
- color: $ui-img-gate-text;
142
+ width: 56px;
143
+ height: 56px;
144
+ font-weight: 800;
145
+ letter-spacing: var(--tracking-tight);
146
+ color: $ui-img-gate-badge-text;
147
+ background-color: $ui-img-gate-badge-bg;
148
+ border: 2px solid $ui-img-gate-badge-border;
149
+ border-radius: $radius-full;
150
+ transform: translate(-50%, -50%);
140
151
  }
141
152
 
142
- @include to-cq(160px) {
153
+ @include to-cq(240px) {
143
154
  .ui-img-gate__label {
144
155
  @include font-xs;
156
+
157
+ width: 40px;
158
+ height: 40px;
145
159
  }
146
160
  }
147
161
 
148
- @include to-cq(56px) {
162
+ @include to-cq(96px) {
149
163
  .ui-img-gate__label {
150
164
  @include font-4xs;
165
+
166
+ width: 26px;
167
+ height: 26px;
168
+ border-width: 1px;
169
+ }
170
+ }
171
+
172
+ @include to-cq(40px) {
173
+ .ui-img-gate__label {
174
+ display: none;
151
175
  }
152
176
  }
153
177
  </style>
@@ -239,10 +239,15 @@ const categoriesChips = computed((): IChipsItem[] => props.data.categories && pr
239
239
  prefix: 'categories',
240
240
  })));
241
241
 
242
- // Над плеером — только топ-категории (`isTop` ставит BFF); без пометки (топ-список не пришёл) — все.
243
- // Теги, совпавшие с нетоповой категорией, всё равно отсекаются по полному списку ниже.
244
- const topCategoriesChips = computed((): IChipsItem[] => (categoriesChips.value ?? [])
245
- .filter((_, index) => props.data.categories[index]?.isTop !== false));
242
+ // Над плеером — категории из группы `top_categories`, как прислал бекенд.
243
+ // Теги, совпавшие с любой категорией ролика, всё равно отсекаются по полному списку ниже.
244
+ const topCategoriesChips = computed((): IChipsItem[] => (props.data.topCategories ?? []).map(item => ({
245
+ title: item.title,
246
+ value: item.name,
247
+ image: item.icon ? `/icons/flags/${item.icon}.svg` : undefined,
248
+ imageFit: 'contain',
249
+ prefix: 'categories',
250
+ })));
246
251
 
247
252
  const categoriesTitles = computed<string[]>(() => props.data.categories && props.data.categories?.map(item => item.title?.replace(/\s+/g, '').toLowerCase() ?? ''));
248
253
 
@@ -1,4 +1,5 @@
1
1
  {
2
+ "scenes": "Szenen",
2
3
  "faphouse": {
3
4
  "promo_text": "❤️ Schau und lade VOLLE Videos in HD & 4K 🔥",
4
5
  "promo_action": "ZUGANG ERHALTEN"
@@ -1,9 +1,10 @@
1
1
  {
2
+ "scenes": "Scenes",
2
3
  "register_to_upload": "Please sign up or log in to upload your videos",
3
4
  "age_gate": {
4
5
  "thumb": "18+",
5
- "title": "Laws in your region require age verification to view 18+ content",
6
- "text": "Takes a minute, and we keep no personal data",
6
+ "title": "Age verification required",
7
+ "text": "Laws in your country or region require you to confirm you are 18+ to view adult content. It takes a minute, and we keep no personal data.",
7
8
  "button": "Verify age",
8
9
  "player_text": "Confirm your age to watch this video"
9
10
  },
@@ -1,4 +1,5 @@
1
1
  {
2
+ "scenes": "Escenas",
2
3
  "faphouse": {
3
4
  "promo_text": "❤️ Mira y descarga vídeos COMPLETOS en HD y 4K 🔥",
4
5
  "promo_action": "OBTENER ACCESO"
@@ -1,4 +1,5 @@
1
1
  {
2
+ "scenes": "Scènes",
2
3
  "faphouse": {
3
4
  "promo_text": "❤️ Regardez et téléchargez des vidéos COMPLÈTES en HD et 4K 🔥",
4
5
  "promo_action": "OBTENIR L’ACCÈS"
@@ -1,4 +1,5 @@
1
1
  {
2
+ "scenes": "Scene",
2
3
  "faphouse": {
3
4
  "promo_text": "❤️ Guarda e scarica video COMPLETI in HD e 4K 🔥",
4
5
  "promo_action": "OTTIENI ACCESSO"
@@ -1,4 +1,5 @@
1
1
  {
2
+ "scenes": "シーン",
2
3
  "faphouse": {
3
4
  "promo_text": "❤️ 動画のフル版をHD・4K画質で視聴・ダウンロード 🔥",
4
5
  "promo_action": "アクセスする"
@@ -1,4 +1,5 @@
1
1
  {
2
+ "scenes": "장면",
2
3
  "faphouse": {
3
4
  "promo_text": "❤️ 영상 전체를 HD·4K 화질로 감상하고 다운로드 🔥",
4
5
  "promo_action": "액세스 받기"
@@ -1,4 +1,5 @@
1
1
  {
2
+ "scenes": "Сцены",
2
3
  "faphouse": {
3
4
  "promo_text": "❤️ Смотрите и скачивайте ПОЛНЫЕ видео в качестве HD и 4K 🔥",
4
5
  "promo_action": "ПОЛУЧИТЬ ДОСТУП"
@@ -1,4 +1,5 @@
1
1
  {
2
+ "scenes": "场景",
2
3
  "faphouse": {
3
4
  "promo_text": "❤️ 观看并下载完整视频,支持 HD 和 4K 🔥",
4
5
  "promo_action": "获取访问权限"
@@ -12,7 +12,9 @@
12
12
 
13
13
  <LayoutSecondNav class="_to-sd"/>
14
14
 
15
- <LayoutAgeBanner/>
15
+ <ClientOnly>
16
+ <LazyLayoutAgePopup v-if="isAgeGated"/>
17
+ </ClientOnly>
16
18
 
17
19
  <main
18
20
  id="main"
@@ -60,5 +62,7 @@ const { snackbarText, isSnackBarInPopup } = useSnackbar();
60
62
 
61
63
  const { isAuthorized } = useUser(AuthorizationApiService);
62
64
 
65
+ const { isAgeGated } = useAgeGate();
66
+
63
67
  const htmlAttrs = computed(() => head.value.htmlAttrs);
64
68
  </script>
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itube-specs",
3
3
  "type": "module",
4
- "version": "0.0.1073",
4
+ "version": "0.0.1075",
5
5
  "main": "./nuxt.config.ts",
6
6
  "types": "./types/index.d.ts",
7
7
  "scripts": {
Binary file
package/runtime/index.ts CHANGED
@@ -47,7 +47,6 @@ export * from './utils/cleaners/clean-video-card';
47
47
  export * from './utils/cleaners/clean-model-info';
48
48
  export * from './utils/cleaners/clean-suggest-models';
49
49
  export * from './utils/video-data-add-model-portraits';
50
- export * from './utils/video-data-mark-top-categories';
51
50
  export * from './utils/converters/convert-categories-to-footer';
52
51
  export * from './utils/converters/convert-categories-to-chips';
53
52
  export * from './utils/converters/convert-tag-group-item-to-chips';
@@ -197,4 +197,21 @@ describe('cleanVideoData — группы под плеером', () => {
197
197
  expect(data.tagGroups[0].items.map(item => item.name)).toEqual(['anal', 'rimming', 'doggy']);
198
198
  expect(data.tags).toEqual(['anal']);
199
199
  });
200
+
201
+ it('v2: top_categories — в topCategories по order, в группы под плеером не попадает', () => {
202
+ const data = cleanVideoData(raw([
203
+ { type: 'category', name: 'actions', title: 'Actions', order: 1, items: [{ name: 'anal', title: 'Anal' }] },
204
+ { type: 'top_categories', name: 'top_categories', title: 'top_categories', order: 2, items: [
205
+ { name: 'hunk', title: 'Hunk', order: 2, icon: '' },
206
+ { name: 'business man', title: 'Businessman', order: 1, icon: '' },
207
+ ] },
208
+ ]));
209
+
210
+ expect(data.topCategories.map(item => item.name)).toEqual(['business man', 'hunk']);
211
+ expect(data.tagGroups.map(group => group.name)).toEqual(['actions']);
212
+ });
213
+
214
+ it('без top_categories — строка над плеером без категорий', () => {
215
+ expect(cleanVideoData(raw(undefined)).topCategories).toEqual([]);
216
+ });
200
217
  });
@@ -1,4 +1,4 @@
1
- import type { IRawVideoData, IVideoData, ISceneGroup, ITimelineGroup, IVideoTagGroup } from '../../../types';
1
+ import type { IRawVideoData, IVideoData, ISceneGroup, ITimelineGroup, IVideoTagGroup, IVideoTagGroupItem } from '../../../types';
2
2
  import { pickPortraitVariant } from '../pick-portrait-variant';
3
3
  import { modelGender } from '../model-placeholder';
4
4
 
@@ -82,6 +82,12 @@ const cleanTagGroups = (raw: IRawVideoData['entities']): IVideoTagGroup[] => (Ar
82
82
  .filter(group => group.items.length > 0)
83
83
  .sort(byOrder);
84
84
 
85
+ // Строка над плеером: элементы группы `top_categories` как есть, без сверки с `categories`.
86
+ const cleanTopCategories = (raw: IRawVideoData['entities']): IVideoTagGroupItem[] => ((Array.isArray(raw) ? raw : [])
87
+ .find(group => group?.type === 'top_categories')?.items ?? [])
88
+ .filter(item => item?.title && item.name)
89
+ .sort(byOrder);
90
+
85
91
  /** Нормализует сырые данные видео к типу IVideoData. */
86
92
  export const cleanVideoData = (data: IRawVideoData, theme?: string): IVideoData => ({
87
93
  guid: data.guid || '',
@@ -96,6 +102,7 @@ export const cleanVideoData = (data: IRawVideoData, theme?: string): IVideoData
96
102
  tagGroups: cleanTagGroups(data.entities),
97
103
  models: cleanVideoModels(data.models, theme),
98
104
  categories: data.categories || [],
105
+ topCategories: cleanTopCategories(data.entities),
99
106
  actions: data.actions || null,
100
107
  channelName: data.channel?.name || '',
101
108
  channelAvatar: data.channel?.avatar_url || '',
@@ -1,6 +1,6 @@
1
1
  import type { IChipsItem, IVideoTagGroupItem, VideoTagGroupType } from '../../../types';
2
2
 
3
- const PREFIX: Record<VideoTagGroupType, string> = { tag: 'tag', category: 'categories', search: 'search' };
3
+ const PREFIX: Record<VideoTagGroupType, string> = { tag: 'tag', category: 'categories', search: 'search', top_categories: 'categories' };
4
4
 
5
5
  /** Элемент группы под плеером (контракт видео v2) → чипса: ссылка по типу группы и машинному `name`, показ по `title`. */
6
6
  export function convertTagGroupItemToChips(type: VideoTagGroupType, item: IVideoTagGroupItem): IChipsItem {
@@ -1,5 +1,5 @@
1
1
  import type { IRawVideoData, IVideoData, IRawModelCard } from '../../../../types';
2
- import { cleanVideoData, videoDataAddModelPortraits, videoDataMarkTopCategories, getUrlWithProxiedParams } from '../../../../runtime';
2
+ import { cleanVideoData, videoDataAddModelPortraits, getUrlWithProxiedParams } from '../../../../runtime';
3
3
 
4
4
  export default defineEventHandler(async (event): Promise<IVideoData> => {
5
5
  const theme = useRuntimeConfig(event).public.theme;
@@ -17,16 +17,15 @@ export default defineEventHandler(async (event): Promise<IVideoData> => {
17
17
  const videoUrl = getUrlWithProxiedParams(`${apiUrl}/v2/video/url/${id}`, event);
18
18
  const modelsUrl = getUrlWithProxiedParams(`${apiUrl}/v2/models/list-by-video-id/${id}`, event);
19
19
 
20
- const [videoRaw, modelsRaw, topCategoryNames] = await Promise.all([
20
+ const [videoRaw, modelsRaw] = await Promise.all([
21
21
  apiFetch(videoUrl, {
22
22
  method: 'GET',
23
23
  headers: clientHeaders,
24
24
  }),
25
25
  apiFetch(modelsUrl, { method: 'GET', headers: clientHeaders }).catch(() => null),
26
- getTopCategoryNames(event),
27
26
  ]);
28
27
 
29
- const videoData = videoDataMarkTopCategories(withDemoFallbacks(videoRaw.data as IRawVideoData), topCategoryNames);
28
+ const videoData = withDemoFallbacks(videoRaw.data as IRawVideoData);
30
29
  const modelsData = modelsRaw?.data as Record<'items', Array<IRawModelCard>>;
31
30
 
32
31
  return cleanVideoData(
@@ -1,4 +1,4 @@
1
- import { cleanVideoData, videoDataAddModelPortraits, videoDataMarkTopCategories, getUrlWithProxiedParams } from '../../../../runtime';
1
+ import { cleanVideoData, videoDataAddModelPortraits, getUrlWithProxiedParams } from '../../../../runtime';
2
2
  import type { IRawVideoData, IVideoData, IRawModelCard } from '../../../../types';
3
3
 
4
4
  export default defineEventHandler(async (event): Promise<IVideoData> => {
@@ -15,16 +15,15 @@ export default defineEventHandler(async (event): Promise<IVideoData> => {
15
15
  const videoUrl = getUrlWithProxiedParams(`${apiUrl}/v2/video/url/${url}`, event);
16
16
  const modelsUrl = getUrlWithProxiedParams(`${apiUrl}/v2/models/list-by-video-url/${url}`, event);
17
17
 
18
- const [videoRaw, modelsRaw, topCategoryNames] = await Promise.all([
18
+ const [videoRaw, modelsRaw] = await Promise.all([
19
19
  apiFetch(videoUrl, {
20
20
  method: 'GET',
21
21
  headers: clientHeaders,
22
22
  }),
23
23
  apiFetch(modelsUrl, { method: 'GET', headers: clientHeaders }).catch(() => null),
24
- getTopCategoryNames(event),
25
24
  ]);
26
25
 
27
- const videoData = videoDataMarkTopCategories(withDemoFallbacks(videoRaw.data as IRawVideoData), topCategoryNames);
26
+ const videoData = withDemoFallbacks(videoRaw.data as IRawVideoData);
28
27
  const modelsData = modelsRaw?.data as Record<'items', Array<IRawModelCard>>;
29
28
 
30
29
  return cleanVideoData(
@@ -37,8 +37,6 @@ export interface IRawVideoData {
37
37
  state?: string,
38
38
  title: string,
39
39
  unknownFields?: string,
40
- /** Не из API: ставит BFF по топ-списку категорий. */
41
- isTop?: boolean
42
40
  }[];
43
41
  actions: null;
44
42
  primary_thumb_rank: number; // unused
@@ -30,7 +30,8 @@ export interface ISceneGroupScene {
30
30
  }
31
31
 
32
32
  /** Тип узла группы (контракт ручки видео v2): по нему строится ссылка элементов — тег, категория или поиск. */
33
- export type VideoTagGroupType = 'tag' | 'category' | 'search';
33
+ /** `top_categories` — не группа под плеером, а список категорий для строки над плеером (`IVideoData.topCategories`). */
34
+ export type VideoTagGroupType = 'tag' | 'category' | 'search' | 'top_categories';
34
35
 
35
36
  /** Элемент группы (контракт ручки видео v2). `name` — машинное название (ссылка), `title` — для показа. */
36
37
  export interface IVideoTagGroupItem {
@@ -82,9 +83,9 @@ export interface IVideoData {
82
83
  name: string;
83
84
  title: string;
84
85
  icon?: string;
85
- /** Топ-категория — попадает в строку над плеером (BFF, по кешу `/v1/categories/top`). */
86
- isTop?: boolean;
87
86
  }[];
87
+ /** Категории строки над плеером — из группы `top_categories` ответа v2, как прислал бекенд. */
88
+ topCategories: IVideoTagGroupItem[];
88
89
  actions: null;
89
90
  channelName: string;
90
91
  channelAvatar: string;
@@ -1,108 +0,0 @@
1
- <!-- Плашка под шапкой, пока возрастная верификация требуется и не пройдена (useAgeGate) -->
2
- <template>
3
- <section
4
- v-if="isAgeGated"
5
- class="layout-age-banner"
6
- >
7
- <div class="layout-age-banner__inner _container">
8
- <p class="layout-age-banner__content">
9
- <strong class="layout-age-banner__title">{{ t('age_gate.title') }}</strong>
10
- <span class="layout-age-banner__text">{{ t('age_gate.text') }}</span>
11
- </p>
12
- <UiBtn
13
- :disabled="isPending"
14
- :title="t('age_gate.button')"
15
- theme="primary"
16
- size="l"
17
- class="layout-age-banner__btn"
18
- @click="onVerify"
19
- >{{ t('age_gate.button') }}</UiBtn>
20
- </div>
21
- </section>
22
- </template>
23
-
24
- <script setup lang="ts">
25
- const { t } = useI18n();
26
- const route = useRoute();
27
- const { isAgeGated, startVerification } = useAgeGate();
28
-
29
- const isPending = ref(false);
30
-
31
- const onVerify = async () => {
32
- isPending.value = true;
33
- try {
34
- await startVerification(route.fullPath);
35
- } finally {
36
- isPending.value = false;
37
- }
38
- };
39
- </script>
40
-
41
- <style lang="scss">
42
- .layout-age-banner {
43
- position: sticky;
44
- top: var(--header-height);
45
- z-index: calc(#{$z-index-header} - 2);
46
- padding-block: 10px;
47
- background-color: $layout-age-banner-bg;
48
-
49
- @include from-br($brkpnt-burger) {
50
- $layout-nav-height: 59px;
51
-
52
- top: calc(var(--header-height) + #{$layout-nav-height});
53
- padding-block: 12px;
54
- }
55
- }
56
-
57
- .layout-age-banner__inner {
58
- display: flex;
59
- flex-direction: column;
60
- align-items: center;
61
- gap: 12px;
62
- text-align: center;
63
-
64
- @include from-br(sm) {
65
- flex-direction: row;
66
- justify-content: space-between;
67
- gap: 32px;
68
- text-align: left;
69
- }
70
- }
71
-
72
- .layout-age-banner__content {
73
- display: flex;
74
- flex-direction: column;
75
- gap: 4px;
76
- }
77
-
78
- .layout-age-banner__title {
79
- @include font-base;
80
-
81
- font-weight: 700;
82
- color: $layout-age-banner-title;
83
-
84
- @include from-br(sm) {
85
- @include font-lg;
86
- }
87
- }
88
-
89
- .layout-age-banner__text {
90
- @include font-sm;
91
-
92
- color: $layout-age-banner-text;
93
-
94
- @include from-br(sm) {
95
- @include font-base;
96
- }
97
- }
98
-
99
- .layout-age-banner__btn {
100
- --button-bg: #{$layout-age-banner-btn-bg};
101
- --button-bg--hover: #{$layout-age-banner-btn-bg--hover};
102
- --button-text: #{$layout-age-banner-btn-text};
103
- --button-border: transparent;
104
-
105
- flex-shrink: 0;
106
- font-weight: 700;
107
- }
108
- </style>
@@ -1,19 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
- import type { IRawVideoData } from '../../../types';
3
- import { videoDataMarkTopCategories } from '../video-data-mark-top-categories';
4
-
5
- const video = {
6
- categories: [{ name: 'cartoon', title: 'Cartoon' }, { name: '3d', title: '3D' }, { name: 'Masturbation', title: 'Masturbation' }],
7
- } as IRawVideoData;
8
-
9
- describe('videoDataMarkTopCategories', () => {
10
- it('marks categories from the top list, ignoring case and edge spaces', () => {
11
- const result = videoDataMarkTopCategories(video, ['cartoon', 'masturbation ']);
12
-
13
- expect(result.categories.map(category => category.isTop)).toEqual([true, false, true]);
14
- });
15
-
16
- it('leaves the video untouched without the top list', () => {
17
- expect(videoDataMarkTopCategories(video, null)).toBe(video);
18
- });
19
- });
@@ -1,21 +0,0 @@
1
- import type { IRawVideoData } from '../../types';
2
-
3
- const normalize = (name: string) => name.trim().toLowerCase();
4
-
5
- /**
6
- * Помечает категории ролика `isTop` по списку топ-категорий: в строку над плеером идут только топовые.
7
- * Списка нет (ручка упала) — ролик без пометок, строка покажет все категории, как раньше.
8
- * Имена сравниваются без регистра и краевых пробелов: в API встречается `masturbation `.
9
- * @param videoData - сырые данные видео
10
- * @param topNames - имена топ-категорий
11
- */
12
- export function videoDataMarkTopCategories(videoData: IRawVideoData, topNames: string[] | null): IRawVideoData {
13
- if (!videoData?.categories?.length || !topNames) return videoData;
14
-
15
- const top = new Set(topNames.map(normalize));
16
-
17
- return {
18
- ...videoData,
19
- categories: videoData.categories.map(category => ({ ...category, isTop: top.has(normalize(category.name)) })),
20
- };
21
- }
@@ -1,14 +0,0 @@
1
- import type { H3Event } from 'h3';
2
- import type { IRawCategoryCard } from '../../types';
3
-
4
- /**
5
- * Имена топ-категорий из кеша BFF (`cachedApiFetch`, 10 минут на домен + язык + нишу): к бекенду ходим
6
- * раз в TTL, а не на каждый ролик. Ошибка ручки — `null`, ролик от этого не падает.
7
- */
8
- export async function getTopCategoryNames(event: H3Event): Promise<string[] | null> {
9
- const url = `${process.env.NUXT_PUBLIC_API_DOMAIN}/v1/categories/top`;
10
-
11
- return cachedApiFetch<Array<IRawCategoryCard>>(url, event)
12
- .then(categories => (Array.isArray(categories) ? categories.map(category => category.name) : null))
13
- .catch(() => null);
14
- }