itube-specs 0.0.1039 → 0.0.1040

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.
Files changed (36) hide show
  1. package/DOCS/CONVENTIONS.md +30 -9
  2. package/SITE_UPDATES.md +65 -0
  3. package/components/adv/native-bottom.vue +0 -1
  4. package/components/adv/native-player.vue +0 -1
  5. package/components/adv/ntv.vue +0 -1
  6. package/components/grid/insert.vue +31 -0
  7. package/components/grid/models-end-slot.vue +16 -0
  8. package/components/grid/models.vue +21 -2
  9. package/components/grid/videos-end-slot.vue +16 -0
  10. package/components/grid/videos.vue +29 -4
  11. package/components/layout/burger.vue +3 -5
  12. package/components/layout/nav.vue +1 -2
  13. package/components/player/core.vue +30 -5
  14. package/components/view/video-after-player-slot.vue +13 -0
  15. package/components/view/video-after-related-slot.vue +13 -0
  16. package/components/view/video.vue +34 -5
  17. package/composables/page/use-category-page.ts +98 -0
  18. package/composables/page/use-channel-page.ts +75 -0
  19. package/composables/page/use-model-page.ts +67 -0
  20. package/composables/page/use-search-page.ts +52 -0
  21. package/composables/use-grid-row-after.ts +53 -0
  22. package/composables/use-navigation-items.ts +48 -0
  23. package/composables/use-player-site-hooks.ts +9 -0
  24. package/composables/use-webcams-share.ts +14 -0
  25. package/package.json +1 -1
  26. package/pages/categories/[slug].vue +12 -85
  27. package/pages/channels/[channel].vue +6 -58
  28. package/pages/models/[slug]/index.vue +5 -44
  29. package/pages/playlists/[playlistId]/[slug].vue +1 -1
  30. package/pages/search/[slug]/index.vue +2 -50
  31. package/pages/videos/[slug].vue +1 -1
  32. package/types/index.d.ts +1 -0
  33. package/types/navigation-items.d.ts +28 -0
  34. package/types/player-site-hooks.d.ts +17 -0
  35. package/utils/adverts-helper.ts +31 -9
  36. package/utils/popunder-helper.ts +13 -3
@@ -16,15 +16,36 @@ namespace; filename must not repeat it.
16
16
  - BEM root class = kebab-case of the name: `<CardVideo>` → `.card-video`
17
17
  - `pathPrefix: true` mandatory — layers break component exports under `false` (nuxt/nuxt#30099)
18
18
 
19
- **App override:** an app defining the same name outranks the layer and wins auto-import; callers untouched.
20
- Don't fork a component when a prop expresses the difference.
21
-
22
- **Small difference → flag or prop in the layer, not an override.** One or two tweaks (hide a block, another
23
- link, another spacing), and anything another site may want too: add a prop, an `appConfig` key (default =
24
- current behaviour, the site sets it in its `config/site.config.ts`) or a feature flag here, plus a
25
- `SITE_UPDATES.md` entry. No copied page or component in an app slot for that — a fork goes stale with every
26
- layer release. Override only when the app's version genuinely differs throughout (e.g. a site's ad-aware
27
- `grid/videos.vue`). Sample: `appConfig.categoriesGrouped` instead of an app copy of `category/nav.vue`.
19
+ ## Extending from a site
20
+
21
+ A site adds its own logic without copying layer code. Take the lowest rung that covers the difference; every
22
+ new extension point ships with a `SITE_UPDATES.md` entry, its default = current behaviour.
23
+
24
+ | Difference | Tool | Sample |
25
+ |---|---|---|
26
+ | A value: label, link, size, show/hide a block every site has | `appConfig` key (site sets it in `config/site.config.ts`) or a feature flag (env-dependent: ads, sections) | `categoriesGrouped`, `navOverrides`, `videoPage`, `gridWebcams` |
27
+ | "Put something here" | **Component slot**: empty `*-slot.vue` next to its owner, rendered by the owner; the site ships a file at the same path | `view/video-after-related-slot.vue` → `<ViewVideoAfterRelatedSlot :data>`, `grid/videos-end-slot.vue` |
28
+ | Insert **and** change inputs (props, own data) | **Wrapper**: same-name app component renders the layer one (`import Layer… from 'itube-specs/components/…'`) through its props and slots | rare: first check whether the input change is a feature every site may want — then it's a key (grids: `gridWebcams` instead of a wrapper fetching webcams) |
29
+ | Behaviour inside a component (ad hooks, pause screen) | **Site hooks composable**: layer calls `use*SiteHooks()` returning `{}`; the site ships the same-name composable | `usePlayerSiteHooks()` → `IPlayerSiteHooks` |
30
+ | Logic (which spot, how to compute) | Override the **helper** (`utils/*-helper.ts`), not the `.vue` — **extend, don't copy**: `class AdvertsHelper extends LayerAdvertsHelper` (`import { AdvertsHelper as LayerAdvertsHelper } from 'itube-specs/utils/adverts-helper'`), `static override` only what differs | `AdvertsHelper.getPrerollSpotType()`, a site's `PopunderHelper` keeps the layer's UTM part |
31
+ | Markup differs throughout | Override the page; its logic comes from the layer's page composables | `useCategoryPageData()` + `useCategoryPage()` |
32
+ | Nothing above fits | Full copy, header `copy of layer X.Y.Z, diff on update` | — target: none |
33
+
34
+ Rules that keep the rungs working:
35
+
36
+ - **Overridable helpers via auto-import only.** A layer file importing `../../utils/adverts-helper` locks out
37
+ the site's `utils/adverts-helper.ts` — that alone forced a fork of `player/core.vue`.
38
+ - **Extended helpers:** what a subclass may reuse is `protected`, not `private`; return types stay compatible
39
+ (`Promise<void>`). The layer module of an extended helper is imported only by its site subclass — importing it
40
+ first elsewhere hits the auto-import cycle (layer helper → site helper → subclass → layer helper) and `extends`
41
+ sees `undefined`. In tests import the site class first.
42
+ - **Component slots:** suffix `-slot`, file next to the component that renders it, empty in the layer. Its
43
+ props are a public contract — changing them is a `SITE_UPDATES.md` entry.
44
+ - **Wrapper:** declare only the props it reads, the rest falls through `$attrs`; forward the caller's slots
45
+ before adding its own. Needed hook missing → add a prop/slot here, don't fork.
46
+ - **Pages:** logic lives in `composables/page/use*Page.ts` — `use*PageData()` (fetches only, async, combinable
47
+ in `Promise.all`) + `use*Page(pageData, options)` (meta, h1, 404, breadcrumbs; sync, called after the `await`
48
+ so Nuxt context is intact). The page's `<script>` is the call plus page-only fetches.
28
49
 
29
50
  ## Markup
30
51
 
package/SITE_UPDATES.md CHANGED
@@ -11,6 +11,71 @@
11
11
  коммиты до бампа); `npm run patch` сам проставит ей номер версии. Формат пункта: что сделать и где,
12
12
  кому именно (всем / сайтам с N), что будет, если не сделать.
13
13
 
14
+ ## 0.0.1040 — 2026-10-19
15
+
16
+ - **Сайтам со своими `utils/adverts-helper.ts` / `utils/popunder-helper.ts` (videosection-redesign):** не копировать
17
+ класс слоя, а наследовать (`class AdvertsHelper extends LayerAdvertsHelper`, импорт слойного по пути
18
+ `itube-specs/utils/adverts-helper`) и переопределять (`static override`) только своё. Для этого в слое: UTM-часть
19
+ `PopunderHelper` (`utmParamsKeys`, `saveUtmParams`) стала `protected`, добавлен `getUtmSource()`; `AdvertsHelper`
20
+ возвращает `Promise<void>` вместо `Promise<undefined>`, а `getAntiAdblockDomain()` делает один запрос на все
21
+ параллельные вызовы (раньше баннеры, попандер и слайдер на старте запрашивали домен каждый сам). Свою копию
22
+ `getAntiAdblockDomain` / UTM-методов удалить. Образец — коммит videosection-redesign с этим изменением. Не
23
+ сделать — копии продолжат работать, но будут отставать от слоя. Остальным — ничего.
24
+
25
+ - **Сайтам со своими страницами категории, поиска, канала, модели (videosection-redesign):** логика этих страниц
26
+ теперь в composable слоя (`composables/page/`), страницы слоя — шаблон плюс их вызов. Пара на страницу:
27
+ `use*PageData()` — запросы (async, можно в `Promise.all` со своими запросами страницы), `use*Page(pageData,
28
+ options)` — мета, h1 и 404, хлебные крошки, производные значения (синхронная, после `await`). Есть
29
+ `useCategoryPage*`, `useChannelPage*`, `useModelPage*` и `useSearchPage(relatedSearch)` (у поиска запросы выдачи
30
+ разные — только логика). Опции меты: `metaSort` — сортировка вместо `?sort`, `metaSecondText` — `{secondText}`.
31
+ В своей странице оставить разметку и свои блоки, скрипт — на эти composable (образец — коммит
32
+ videosection-redesign с этим изменением). Не сделать — ничего не сломается, но логика страницы останется копией.
33
+ Попутно: хлебные крошки категории, канала, модели и h1 теперь реактивны (раньше считались один раз при setup).
34
+
35
+ - **Всем сайтам:** на страницах видео и плейлиста теперь показывается блок «Recent searches» (поисковые фразы под
36
+ похожими). Раньше страница всегда передавала туда пустой список: проверка `typeof relatedSearch === 'object'`
37
+ срабатывала на самом массиве. Делать ничего не надо; если блок на сайте не нужен — сказать, заведём ключ.
38
+ - **Сайтам с форком `components/view/video.vue` (videosection-redesign):** состав страницы видео задаётся
39
+ `appConfig.videoPage` (через `config/site.config.ts`): `refererPhrases` — фразы, по которым пришли на ролик, над
40
+ заголовком; `relatedPagination`, `relatedModels` — пагинация похожих и карусель моделей; `webcamsBlock` — блок
41
+ Live Cams после похожих, `webcamInserts` — сколько моделей из хвоста его списка уходит во врезки похожих. Свои
42
+ блоки — в точки расширения: `components/view/video-after-player-slot.vue` (под плеером) и
43
+ `components/view/video-after-related-slot.vue` (после похожих и Live), в слое они пустые, сайт кладёт файлы с теми же
44
+ именами (компоненты-слоты — всегда с суффиксом `-slot`). После этого удалить форк `view/video.vue` (образец — коммит videosection-redesign с этим изменением).
45
+ Не сделать — форк продолжит работать, но будет отставать от слоя. Остальным — ничего, по умолчанию страница как
46
+ была.
47
+
48
+ - **Сайтам с форками `components/grid/videos.vue` / `components/grid/models.vue` (videosection-redesign):** всё
49
+ сайтовое в сетках — без форка и без обёртки. `appConfig.gridWebcams` (через `config/site.config.ts`): `videos: {
50
+ afterRows, inserts }` — блок Live Cams после N-й строки карточек и врезки вебкам-тумб (хвост того же списка) в сетке
51
+ видео листинга; `models: { afterRows }` — блок в основной сетке моделей. `appConfig.cardTriggerClass` — класс
52
+ кликабельной части карточек (например, триггер попандера). Свои вставки в конец сеток — компоненты-слоты
53
+ `components/grid/videos-end-slot.vue` (`items`, `related`) и `components/grid/models-end-slot.vue` (`items`,
54
+ `footer`), в слое пустые. Новое общее: `GridInsert` (`:after-rows`) — полноширинная вставка после N-й строки
55
+ карточек при любом числе колонок; `useGridRowAfter` / `placeAfterCardRows` переехали в слой — свой
56
+ `composables/use-grid-row-after.ts` удалить (иначе перекроет слойный); `useWebcamsShare(reserve)` — один список на
57
+ блок Live Cams и врезки. После этого удалить форки сеток (образец — коммит videosection-redesign с этим
58
+ изменением). Не сделать — форки продолжат работать, но будут отставать от слоя. Остальным — ничего, по умолчанию
59
+ сетки как были.
60
+ - **Сайтам с форком `components/player/core.vue` (videosection-redesign, hdsex-remaster):** всё сайтовое в плеере
61
+ теперь подключается без копии — композаблом `usePlayerSiteHooks()` (тип `IPlayerSiteHooks`): `rootClass` — класс
62
+ корня, `pauseScreen: { enabled, component }` — экран паузы (компонент получает `@close`, пересоздаётся на каждой
63
+ паузе), `onReady()` — на монтировании и на `ready` ядра. Сайт кладёт одноимённый композабл в свой `composables/`.
64
+ Спот преролла слой берёт из `AdvertsHelper.getPrerollSpotType()` (в слое — Primary), а сам `AdvertsHelper` и
65
+ `BannerHelper` — через автоимпорт, так что `utils/adverts-helper.ts` / `utils/banner-helper.ts` сайта теперь
66
+ доходят и до компонентов слоя. После этого удалить форк `player/core.vue` (образец — коммит videosection-redesign
67
+ с этим изменением). Не сделать — форк продолжит работать, но будет отставать от слоя. Сайтам со своим
68
+ `utils/adverts-helper.ts` без `getPrerollSpotType()` — добавить его, иначе преролл упадёт на вызове. Остальным —
69
+ ничего.
70
+
71
+ - **Сайтам, где меню отличается от слоя (videosection-redesign):** главная навигация (шапка и бургер) теперь из
72
+ композабла слоя `useNavigationItems()`, правки — в `appConfig` (через `config/site.config.ts`):
73
+ `navOverrides` — правка пункта слоя по его `title` (`title` — другая подпись, `link` — адрес или адреса по
74
+ нишам, `featureFlag`), `navExtraItems` — свои пункты (`after` — за каким пунктом слоя встать). После этого
75
+ удалить проектные оверрайды `components/layout/nav.vue`, `components/layout/burger.vue` и свой композабл пунктов
76
+ меню (образец — коммит videosection-redesign с этим изменением). Не сделать — оверрайды продолжат работать, но
77
+ копия `burger.vue` будет отставать от слоя. Остальным сайтам — ничего, по умолчанию меню как было.
78
+
14
79
  ## 0.0.1036 — 2026-10-14
15
80
 
16
81
  - **Сайтам с `FeatureWebcamsEnabled`:** UI-ключи вебкам-блоков `webcams.*` (заголовки, счётчики онлайна, карточка,
@@ -12,7 +12,6 @@
12
12
  </template>
13
13
 
14
14
  <script setup lang="ts">
15
- import { BannerHelper } from '../../utils/banner-helper';
16
15
  import { AdBannerType, AdSpotType } from '../../runtime';
17
16
  </script>
18
17
 
@@ -12,7 +12,6 @@
12
12
  </template>
13
13
 
14
14
  <script setup lang="ts">
15
- import { BannerHelper } from '../../utils/banner-helper';
16
15
  import { AdBannerType, AdSpotType } from '../../runtime';
17
16
  </script>
18
17
 
@@ -8,7 +8,6 @@
8
8
 
9
9
  <script setup lang="ts">
10
10
 
11
- import { BannerHelper } from '../../utils/banner-helper';
12
11
  import { AdBannerType, AdSpotType } from '../../runtime';
13
12
  </script>
14
13
 
@@ -0,0 +1,31 @@
1
+ <!-- Полноширинная вставка в CSS-сетку карточек (`GridVideos`, `GridModels`): встаёт после N-й строки карточек
2
+ при любом числе колонок (`useGridRowAfter`), пустая места не занимает. Класть прямым потомком сетки — через её слоты. -->
3
+ <template>
4
+ <div
5
+ ref="root"
6
+ class="grid-insert"
7
+ >
8
+ <slot/>
9
+ </div>
10
+ </template>
11
+
12
+ <script setup lang="ts">
13
+ const props = defineProps<{
14
+ /** После какой строки карточек встать. */
15
+ afterRows: number
16
+ }>();
17
+
18
+ const root = ref<HTMLElement | null>(null);
19
+
20
+ useGridRowAfter(root, () => props.afterRows);
21
+ </script>
22
+
23
+ <style lang="scss">
24
+ .grid-insert {
25
+ grid-column: 1 / -1;
26
+ }
27
+
28
+ .grid-insert:empty {
29
+ display: none;
30
+ }
31
+ </style>
@@ -0,0 +1,16 @@
1
+ <!-- Точка расширения сетки моделей — в конце сетки, прямым потомком CSS-сетки (полноширинные вставки ставить через
2
+ `GridInsert` или своим `grid-column: 1 / -1`). В слое пустая; сайт кладёт свой `components/grid/models-end-slot.vue`
3
+ (реклама и т.п.) — он подменяет этот. -->
4
+ <template>
5
+ <template v-if="false"/>
6
+ </template>
7
+
8
+ <script setup lang="ts">
9
+ import type { IModelCard } from '../../types';
10
+
11
+ defineProps<{
12
+ items: IModelCard[]
13
+ /** Сетка в футере страницы. */
14
+ footer?: boolean
15
+ }>();
16
+ </script>
@@ -1,4 +1,5 @@
1
- <!-- Сетка карточек моделей с ленивой гидрацией (первые 6 eager) -->
1
+ <!-- Сетка карточек моделей с ленивой гидрацией (первые 6 eager). Блок Live Cams — по `appConfig.gridWebcams.models`;
2
+ слот — полноширинные вставки (`GridInsert`); в конце — `GridModelsEndSlot` (пустой; сайт кладёт свой). -->
2
3
  <template>
3
4
  <div
4
5
  :class="{'--footer': footer}"
@@ -10,19 +11,37 @@
10
11
  :card="item"
11
12
  :loading="index > 5 ? 'lazy' : 'eager'"
12
13
  :priority="priority && index === 0"
14
+ :class="cardTriggerClass"
13
15
  class="grid-models__item"
14
16
  />
17
+ <GridInsert
18
+ v-if="webcams"
19
+ :after-rows="webcams.afterRows"
20
+ >
21
+ <LazyWebcamMain hydrate-on-visible/>
22
+ </GridInsert>
23
+ <slot/>
24
+ <GridModelsEndSlot
25
+ :items="items"
26
+ :footer="footer"
27
+ />
15
28
  </div>
16
29
  </template>
17
30
 
18
31
  <script setup lang="ts">
19
32
  import type { IModelCard } from '../../types';
20
33
 
21
- defineProps<{
34
+ const props = defineProps<{
22
35
  items: IModelCard[]
23
36
  footer?: boolean
24
37
  priority?: boolean
25
38
  }>();
39
+
40
+ const appConfig = useAppConfig();
41
+ const cardTriggerClass = appConfig.cardTriggerClass || undefined;
42
+
43
+ /** Блок Live Cams — только в основной сетке страницы (`priority`), не в футере и не в сетках по буквам. */
44
+ const webcams = !props.footer && props.priority ? appConfig.gridWebcams?.models : null;
26
45
  </script>
27
46
 
28
47
  <style lang="scss">
@@ -0,0 +1,16 @@
1
+ <!-- Точка расширения сетки видео — в конце сетки, прямым потомком CSS-сетки (полноширинные вставки ставить через
2
+ `GridInsert` или своим `grid-column: 1 / -1`). В слое пустая; сайт кладёт свой `components/grid/videos-end-slot.vue`
3
+ (реклама в листингах и т.п.) — он подменяет этот. -->
4
+ <template>
5
+ <template v-if="false"/>
6
+ </template>
7
+
8
+ <script setup lang="ts">
9
+ import type { IVideoCard } from '../../types';
10
+
11
+ defineProps<{
12
+ items: IVideoCard[]
13
+ /** Сетка похожих на странице видео. */
14
+ related?: boolean
15
+ }>();
16
+ </script>
@@ -3,7 +3,8 @@
3
3
  Врезки: если передать `inserts` (вебкам-тумбы), сетка расставит их между
4
4
  карточками — позиции считает `webcamInsertPositions` по правилам боевого движка.
5
5
  Тумба занимает обычную ячейку сетки и попадает в тот же уровень гидрации, что и
6
- карточка на её месте. -->
6
+ карточка на её месте. Без `inserts` в листинге врезки и блок Live Cams ставит сама по `appConfig.gridWebcams.videos`.
7
+ В конце сетки — `GridVideosEndSlot` (пустой; сайт кладёт свой: реклама и т.п.). -->
7
8
  <template>
8
9
  <div
9
10
  :class="[
@@ -32,10 +33,20 @@
32
33
  :top-chips="topChips"
33
34
  :playlist-id="playlistId"
34
35
  :playlist="playlist"
36
+ :link-class="cardTriggerClass"
35
37
  class="grid-videos__card"
36
38
  />
37
39
  </template>
38
40
  <slot/>
41
+ <GridInsert
42
+ v-if="webcams"
43
+ :after-rows="webcams.afterRows"
44
+ >
45
+ <LazyWebcamMain
46
+ :reserve-tail="webcams.inserts"
47
+ hydrate-on-visible
48
+ />
49
+ </GridInsert>
39
50
  <template
40
51
  v-for="cell in idleCells"
41
52
  :key="`idle-${cell.key}`"
@@ -53,6 +64,7 @@
53
64
  :top-chips="topChips"
54
65
  :playlist-id="playlistId"
55
66
  :playlist="playlist"
67
+ :link-class="cardTriggerClass"
56
68
  class="grid-videos__card"
57
69
  loading="lazy"
58
70
  hydrate-on-idle
@@ -76,11 +88,16 @@
76
88
  :playlist-id="playlistId"
77
89
  :playlist="playlist"
78
90
  :hydrate-on-visible="HYDRATE_MARGIN"
91
+ :link-class="cardTriggerClass"
79
92
  class="grid-videos__card"
80
93
  loading="lazy"
81
94
  />
82
95
  </template>
83
96
  <slot name="grid-end"/>
97
+ <GridVideosEndSlot
98
+ :items="items"
99
+ :related="related"
100
+ />
84
101
  </div>
85
102
  </template>
86
103
 
@@ -132,15 +149,23 @@ function onInsertView(cell: IGridCell, element: HTMLElement) {
132
149
  if (cell.webcam) emit('model-view', { model: cell.webcam, index: cell.insertIndex, element });
133
150
  }
134
151
 
152
+ const appConfig = useAppConfig();
153
+ const cardTriggerClass = appConfig.cardTriggerClass || undefined;
154
+
155
+ /** Блок Live Cams и свои врезки — у листингов (похожие и плейлист без них, страница видео передаёт врезки сама). */
156
+ const webcams = !props.inserts && !props.related && !props.playlist ? appConfig.gridWebcams?.videos : null;
157
+ const ownInserts = webcams ? await useWebcamsShare(webcams.inserts) : null;
158
+ const inserts = computed(() => props.inserts || ownInserts?.value || []);
159
+
135
160
  const isMobile = useState<boolean>('isMobile');
136
161
  const eagerCount = computed(() => isMobile.value ? 4 : 12);
137
162
 
138
163
  const cells = computed<IGridCell[]>(() => {
139
- const inserts = props.inserts || [];
164
+ const models = inserts.value;
140
165
  const positions = webcamInsertPositions(
141
166
  props.insertSeed || props.items[0]?.guid || '',
142
167
  props.items.length,
143
- inserts.length,
168
+ models.length,
144
169
  );
145
170
 
146
171
  const result: IGridCell[] = [];
@@ -148,7 +173,7 @@ const cells = computed<IGridCell[]>(() => {
148
173
 
149
174
  props.items.forEach((video, index) => {
150
175
  if (positions[inserted] === index) {
151
- const model = inserts[inserted];
176
+ const model = models[inserted];
152
177
 
153
178
  result.push({
154
179
  key: `webcam-${model.id || model.username}`,
@@ -89,13 +89,11 @@ import type { IProfileData } from '../../types';
89
89
  import { CSS_BREAKPOINTS } from '../../config/css-breakpoints';
90
90
  import { AuthorizationApiService } from '../../services/api/authorization.service';
91
91
 
92
- import { getNavigationItems } from '../../lib';
93
-
94
92
  const { t } = useI18n();
95
93
  const { generateLink } = useGenerateLink();
96
- const navigationItems = getNavigationItems();
97
- const topItems = navigationItems.filter(item => item.top);
98
- const restItems = navigationItems.filter(item => !item.top);
94
+ const navigationItems = useNavigationItems();
95
+ const topItems = computed(() => navigationItems.value.filter(item => item.top));
96
+ const restItems = computed(() => navigationItems.value.filter(item => !item.top));
99
97
 
100
98
  const openedChilds = ref<string | null>(null);
101
99
 
@@ -53,14 +53,13 @@
53
53
  </template>
54
54
 
55
55
  <script setup lang="ts">
56
- import { getNavigationItems } from '../../lib';
57
56
  import type { INavigationItems } from '../../types';
58
57
 
59
58
  defineProps<{
60
59
  items?: INavigationItems[];
61
60
  }>();
62
61
 
63
- const navigationItems = getNavigationItems();
62
+ const navigationItems = useNavigationItems();
64
63
 
65
64
  const { generateLink } = useGenerateLink();
66
65
  const route = useRoute();
@@ -9,6 +9,7 @@
9
9
  :source="source"
10
10
  :options="options"
11
11
  :ad-config="loadAdConfig"
12
+ :class="siteHooks.rootClass"
12
13
  class="player-core"
13
14
  lazy
14
15
  load-on="placeholder"
@@ -16,7 +17,20 @@
16
17
  @advancerequest="onAdvanceRequest"
17
18
  @sourcechange="onSourceChange"
18
19
  @menurequest="onMenuRequest"
19
- />
20
+ @pause="onPause"
21
+ @ready="siteHooks.onReady?.()"
22
+ >
23
+ <template
24
+ v-if="siteHooks.pauseScreen"
25
+ #pauseScreen="{ close }"
26
+ >
27
+ <component
28
+ :is="siteHooks.pauseScreen.component"
29
+ :key="`pause-screen-${pauseCount}`"
30
+ @close="close"
31
+ />
32
+ </template>
33
+ </ITubePlayer>
20
34
 
21
35
  <!-- Внешнее меню плеера в нашем ui-popup (только мобила — menus: 'external-mobile'). -->
22
36
  <PlayerMenu
@@ -29,8 +43,7 @@
29
43
  import { ITubePlayer } from 'itube-modern-player/vue';
30
44
 
31
45
  import type { Player, PlayerOptions, VideoSource, LocaleCode, AdsOptions, PreviewMetaItem, SceneGroup, MenuRequestPayload } from 'itube-modern-player';
32
- import { AdvertsHelper } from '../../utils/adverts-helper';
33
- import { AdSpotType, ThumbSize } from '../../runtime';
46
+ import { ThumbSize } from '../../runtime';
34
47
  import type { IVideoData, IVideoCard } from '../../types';
35
48
  import playIcon from '../../assets/icons/player/play.svg?raw';
36
49
  import fullscreenIcon from '../../assets/icons/player/fullscreen.svg?raw';
@@ -65,6 +78,18 @@ const emit = defineEmits<{
65
78
 
66
79
  const { t, n, locale } = useI18n();
67
80
 
81
+ const siteHooks = usePlayerSiteHooks();
82
+ const isPauseScreen = computed(() => !!siteHooks.pauseScreen?.enabled.value);
83
+
84
+ // Каждая пауза — новый экран: счётчик в `key` пересоздаёт его содержимое.
85
+ const pauseCount = ref(0);
86
+
87
+ function onPause() {
88
+ if (isPauseScreen.value) pauseCount.value += 1;
89
+ }
90
+
91
+ onMounted(() => siteHooks.onReady?.());
92
+
68
93
  const playerRef = ref<InstanceType<typeof ITubePlayer> & { player: Player | null }>();
69
94
 
70
95
  // Внешнее меню (menus: 'external-mobile'): по нажатию меню-кнопки плеер эмитит модель,
@@ -286,7 +311,7 @@ const options = computed<Omit<PlayerOptions, 'source'>>(() => ({
286
311
  playlist: {
287
312
  externalAdvance: true,
288
313
  },
289
- pauseScreen: false,
314
+ pauseScreen: isPauseScreen.value,
290
315
  language: playerLanguage.value,
291
316
  // Десктоп — нативные попапы плеера; мобила (≤767px) — эмитит menurequest, рисуем сами.
292
317
  menus: 'external-mobile',
@@ -313,7 +338,7 @@ const options = computed<Omit<PlayerOptions, 'source'>>(() => ({
313
338
  // с готовым adConfig — нам не нужно синхронизировать VAST-запрос с инициализацией.
314
339
  async function loadAdConfig(): Promise<AdsOptions | undefined> {
315
340
  try {
316
- const vastTag = await AdvertsHelper.getPrerollVastLink(AdSpotType.Primary);
341
+ const vastTag = await AdvertsHelper.getPrerollVastLink(AdvertsHelper.getPrerollSpotType());
317
342
  if (vastTag && vastTag.trim() !== '') {
318
343
  return { adList: [{ roll: 'preRoll', vastTag }], skipDelay: 5 };
319
344
  }
@@ -0,0 +1,13 @@
1
+ <!-- Точка расширения страницы видео — сразу под плеером. В слое пустая; сайт кладёт свой
2
+ `components/view/video-after-player-slot.vue` (реклама, партнёрские блоки) — он подменяет этот. -->
3
+ <template>
4
+ <template v-if="false"/>
5
+ </template>
6
+
7
+ <script setup lang="ts">
8
+ import type { IVideoData } from '../../types';
9
+
10
+ defineProps<{
11
+ data: IVideoData
12
+ }>();
13
+ </script>
@@ -0,0 +1,13 @@
1
+ <!-- Точка расширения страницы видео — после похожих (и блока Live Cams, если он включён), перед поисковыми
2
+ фразами. В слое пустая; сайт кладёт свой `components/view/video-after-related-slot.vue` — он подменяет этот. -->
3
+ <template>
4
+ <template v-if="false"/>
5
+ </template>
6
+
7
+ <script setup lang="ts">
8
+ import type { IVideoData } from '../../types';
9
+
10
+ defineProps<{
11
+ data: IVideoData
12
+ }>();
13
+ </script>
@@ -1,6 +1,12 @@
1
1
  <!-- Страница одного видео: плеер, заголовок с моделями/тегами, похожие -->
2
2
  <template>
3
3
  <div class="view-video _page _container">
4
+ <UiExpandRow
5
+ v-if="refererChips.length"
6
+ :items="refererChips"
7
+ class="_mb-16"
8
+ />
9
+
4
10
  <header class="view-video__header">
5
11
  <h1 class="view-video__title">
6
12
  <span class="_truncate">{{ data.title }}</span>
@@ -43,6 +49,8 @@
43
49
  <slot/>
44
50
  </PlayerMain>
45
51
 
52
+ <ViewVideoAfterPlayerSlot :data="data"/>
53
+
46
54
  <section
47
55
  v-if="related"
48
56
  :class="{'_loading': status === 'pending'}"
@@ -77,7 +85,7 @@
77
85
  </section>
78
86
 
79
87
  <LazyUiPagination
80
- v-if="total"
88
+ v-if="total && videoPage.relatedPagination"
81
89
  arrow-icon
82
90
  :key="`pagination-${total}`"
83
91
  :total="total"
@@ -87,13 +95,21 @@
87
95
  />
88
96
 
89
97
  <LazySectionSwiperCards
90
- v-if="models"
98
+ v-if="models && videoPage.relatedModels"
91
99
  :items="modelsRoundCards"
92
100
  :title="$t('related_models')"
93
101
  class="view-video__popular-models"
94
102
  hydrate-on-visible
95
103
  />
96
104
 
105
+ <WebcamMain
106
+ v-if="videoPage.webcamsBlock"
107
+ :reserve-tail="videoPage.webcamInserts"
108
+ class="_mt-24"
109
+ />
110
+
111
+ <ViewVideoAfterRelatedSlot :data="data"/>
112
+
97
113
  <section
98
114
  v-if="recentSearches && recentSearches.length > 0"
99
115
  class="view-video__referer"
@@ -111,6 +127,7 @@
111
127
  <script setup lang="ts">
112
128
  import type { IChipsItem, IModelCard, IStripcashModel, IVideoCard, IVideoData, PaginatedResponse } from '../../types';
113
129
  import { convertModelToRoundCard } from '../../runtime';
130
+ import { VideosApiService } from '../../services/api/videos.service';
114
131
  import type { AsyncDataRequestStatus } from '#app';
115
132
 
116
133
  const featureFlags = useRuntimeConfig().public.featureFlags;
@@ -136,10 +153,22 @@ const props = defineProps<{
136
153
 
137
154
  const { t } = useI18n();
138
155
 
156
+ const videoPage = useAppConfig().videoPage;
157
+
139
158
  // врезки вебкам-тумб в сетку похожих — там же, где они стоят на боевом движке.
140
- // Запрос клиентский: сетка ниже первого экрана, и рендер страницы видео не должен
141
- // ждать провайдера. Без флага `FeatureWebcamsEnabled` запроса нет вовсе
142
- const { items: webcamInserts } = await useFetchWebcams({ clientOnly: true });
159
+ // Без блока Live Cams запрос клиентский: сетка ниже первого экрана, и рендер страницы видео не должен
160
+ // ждать провайдера. С блоком список один на двоих (`useWebcamsShare`).
161
+ // Без флага `FeatureWebcamsEnabled` запроса нет вовсе
162
+ const webcamInserts = videoPage.webcamsBlock
163
+ ? await useWebcamsShare(videoPage.webcamInserts)
164
+ : (await useFetchWebcams({ clientOnly: true })).items;
165
+
166
+ const { data: refererPhrases } = videoPage.refererPhrases
167
+ ? await useFetchVideoRefererPhrases(featureFlags.FetchByUrlEnabled ? VideosApiService.refererPhrasesByUrl : VideosApiService.refererPhrasesById)
168
+ : { data: ref<string[] | null>(null) };
169
+
170
+ const refererChips = computed<IChipsItem[]>(() => (Array.isArray(refererPhrases.value) ? refererPhrases.value : [])
171
+ .map(item => ({ title: item, value: item, prefix: 'search' })));
143
172
 
144
173
  const modelsRoundCards = computed(() =>
145
174
  props.models?.map(convertModelToRoundCard) || []