itube-specs 0.0.892 → 0.0.893

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/pages/library.vue CHANGED
@@ -1,366 +1,6 @@
1
- <!-- Библиотека: «продолжить просмотр», коллекции и подписки. Данные пока заглушка -->
2
1
  <template>
3
- <div class="library _page _container">
4
- <UiBreadcrumbs
5
- :items="breadcrumbs"
6
- class="_breadcrumbs"
7
- />
8
-
9
- <div class="library__header">
10
- <h1 class="library__title _title">{{ t('navigation.library') }}</h1>
11
-
12
- <UiToggle
13
- v-model="saveHistory"
14
- class="library__save"
15
- left
16
- >{{ t('history_text') }}</UiToggle>
17
- </div>
18
-
19
- <section
20
- v-if="continueWatching.length > 0"
21
- class="library__section"
22
- >
23
- <header class="library__section-header">
24
- <h2 class="library__heading">
25
- <span class="library__heading-icon">
26
- <UiIcon
27
- name="history"
28
- size="16"
29
- />
30
- </span>
31
- {{ t('library.continue_watching') }}
32
- </h2>
33
-
34
- <UiLink
35
- :to="generateLink('/history')"
36
- theme="secondary"
37
- >
38
- {{ t('library.view_all_history') }}
39
- <UiIcon
40
- name="arrow-right"
41
- size="16"
42
- />
43
- </UiLink>
44
- </header>
45
-
46
- <div class="library__continue">
47
- <CardHistory
48
- v-for="item in continueWatching"
49
- :key="`continue-${item.guid}`"
50
- :card="item"
51
- loading="eager"
52
- priority
53
- remaining
54
- @share="onShare"
55
- @remove="onRemove"
56
- />
57
- </div>
58
- </section>
59
-
60
- <section class="library__section">
61
- <header class="library__section-header">
62
- <h2 class="library__heading">
63
- <span class="library__heading-icon">
64
- <UiIcon
65
- name="layers"
66
- size="16"
67
- />
68
- </span>
69
- {{ t('library.collections') }}
70
- </h2>
71
- </header>
72
-
73
- <ul class="library__grid">
74
- <li
75
- v-for="item in collections"
76
- :key="item.title"
77
- >
78
- <LibraryItem
79
- :to="item.to"
80
- :icon="item.icon"
81
- :title="item.title"
82
- :subtitle="item.subtitle"
83
- />
84
- </li>
85
- </ul>
86
- </section>
87
-
88
- <section class="library__section">
89
- <header class="library__section-header">
90
- <h2 class="library__heading">
91
- <span class="library__heading-icon">
92
- <UiIcon
93
- name="plus"
94
- size="16"
95
- />
96
- </span>
97
- {{ t('library.following') }}
98
- </h2>
99
-
100
- <UiBtn theme="secondary">
101
- {{ t('library.whats_new', { count: newTotal }) }}
102
- <UiIcon
103
- name="arrow-right"
104
- size="16"
105
- />
106
- </UiBtn>
107
- </header>
108
-
109
- <ul class="library__grid">
110
- <li
111
- v-for="item in following"
112
- :key="item.title"
113
- >
114
- <LibraryItem
115
- :to="item.to"
116
- :icon="item.icon"
117
- :title="item.title"
118
- :subtitle="item.subtitle"
119
- :badge="item.badge"
120
- :badge-note="item.badgeNote"
121
- :badge-theme="item.badgeTheme"
122
- />
123
- </li>
124
- </ul>
125
- </section>
126
-
127
- <ShareMain
128
- v-model="isShareOpen"
129
- :url="shareUrl"
130
- >
131
- <template #card>
132
- <CardVideoMini
133
- v-if="shareCard"
134
- :card="shareCard"
135
- />
136
- </template>
137
- </ShareMain>
138
- </div>
2
+ <NuxtPage />
139
3
  </template>
140
4
 
141
5
  <script setup lang="ts">
142
- import { getHistoryStub } from '../stubs/history';
143
- import type { HistoryStubCard } from '../stubs/history';
144
- import { libraryStub } from '../stubs/library';
145
- import type { IBreadcrumbItem, IVideoCard } from '../types';
146
-
147
- /** Карточка-ссылка библиотеки в разметке страницы. */
148
- type LibraryLink = {
149
- to: string
150
- icon: string
151
- title: string
152
- subtitle?: string
153
- badge?: string
154
- badgeNote?: string
155
- badgeTheme?: 'accent' | 'bordered'
156
- };
157
-
158
- const CONTINUE_LIMIT = 4;
159
-
160
- const { t } = useI18n();
161
-
162
- const { generateLink } = useGenerateLink();
163
-
164
- const saveHistory = ref(true);
165
-
166
- const removedGuids = ref<string[]>([]);
167
-
168
- /** Недосмотренные ролики: прогресс есть, но до конца не дошли. */
169
- const continueWatching = computed(() => getHistoryStub()
170
- .filter(item => item.progress > 0 && item.progress < 100)
171
- .filter(item => !removedGuids.value.includes(item.guid))
172
- .slice(0, CONTINUE_LIMIT));
173
-
174
- const newTotal = computed(() => libraryStub.newModels + libraryStub.newChannels + libraryStub.newPlaylists);
175
-
176
- const collections = computed<LibraryLink[]>(() => [
177
- {
178
- to: generateLink('/favorites'),
179
- icon: 'heart-stroke',
180
- title: t('favorites'),
181
- subtitle: t('plural.videos', libraryStub.favorites),
182
- },
183
- {
184
- to: `${generateLink('/favorites')}?tab=watch-later`,
185
- icon: 'clock',
186
- title: t('watch_later'),
187
- subtitle: t('plural.videos', libraryStub.watchLater),
188
- },
189
- {
190
- to: generateLink('/playlists'),
191
- icon: 'playlist',
192
- title: t('my_playlists'),
193
- subtitle: t('library.playlists_visibility', {
194
- privateCount: libraryStub.privatePlaylists,
195
- publicCount: libraryStub.publicPlaylists,
196
- }),
197
- },
198
- {
199
- to: generateLink('/history'),
200
- icon: 'history',
201
- title: t('history'),
202
- subtitle: t('library.watched_today', { count: libraryStub.watchedToday }),
203
- },
204
- ]);
205
-
206
- const following = computed<LibraryLink[]>(() => [
207
- {
208
- to: generateLink('/models'),
209
- icon: 'user',
210
- title: t('models'),
211
- subtitle: t('plural.models', libraryStub.models),
212
- badge: t('library.new_count', { count: libraryStub.newModels }),
213
- },
214
- {
215
- to: generateLink('/channels'),
216
- icon: 'play-circle',
217
- title: t('channels'),
218
- subtitle: t('plural.channels', libraryStub.channels),
219
- badge: t('library.new_count', { count: libraryStub.newChannels }),
220
- },
221
- {
222
- to: generateLink('/categories'),
223
- icon: 'grid',
224
- title: t('categories'),
225
- subtitle: t('plural.categories', libraryStub.categories),
226
- badge: `+${libraryStub.categoryVideosPerWeek}`,
227
- badgeNote: t('library.this_week'),
228
- badgeTheme: 'bordered',
229
- },
230
- {
231
- to: generateLink('/playlists'),
232
- icon: 'playlist',
233
- title: t('playlists'),
234
- subtitle: t('plural.playlists', libraryStub.playlists),
235
- badge: t('library.new_count', { count: libraryStub.newPlaylists }),
236
- },
237
- ]);
238
-
239
- function onRemove(card: HistoryStubCard) {
240
- removedGuids.value = [...removedGuids.value, card.guid];
241
- }
242
-
243
- const isShareOpen = ref(false);
244
- const shareCard = ref<IVideoCard | null>(null);
245
-
246
- const shareUrl = computed(() => shareCard.value ? generateLink(`/videos/${shareCard.value.id}`) : '');
247
-
248
- function onShare(card: IVideoCard) {
249
- shareCard.value = card;
250
- isShareOpen.value = true;
251
- }
252
-
253
- useNoindex();
254
-
255
- const { brandName } = useAppConfig();
256
-
257
- useHead(() => ({
258
- title: `${t('navigation.library')} — ${brandName}`,
259
- }));
260
-
261
- const breadcrumbs = [
262
- {
263
- name: `${t('navigation.main')}`,
264
- to: '/',
265
- },
266
- {
267
- name: `${t('navigation.library')}`,
268
- },
269
- ] as IBreadcrumbItem[];
270
6
  </script>
271
-
272
- <style lang="scss">
273
- .library__header {
274
- display: flex;
275
- flex-wrap: wrap;
276
- align-items: center;
277
- justify-content: space-between;
278
- column-gap: 16px;
279
- row-gap: 12px;
280
- margin-bottom: 24px;
281
- }
282
-
283
- .library__title._title {
284
- margin-bottom: 0;
285
- }
286
-
287
- .library__save .ui-toggle__text {
288
- @include font-sm;
289
-
290
- color: $page-library-toggle-text;
291
- }
292
-
293
- .library__section {
294
- margin-bottom: 48px;
295
- }
296
-
297
- .library__section-header {
298
- display: flex;
299
- flex-wrap: wrap;
300
- align-items: center;
301
- justify-content: space-between;
302
- column-gap: 16px;
303
- row-gap: 12px;
304
- margin-bottom: 16px;
305
- }
306
-
307
- .library__heading {
308
- @include font-xl;
309
-
310
- display: flex;
311
- align-items: center;
312
- column-gap: 8px;
313
- margin: 0;
314
- font-weight: 600;
315
- color: $page-library-heading;
316
- }
317
-
318
- .library__heading-icon {
319
- display: grid;
320
- place-content: center;
321
- flex-shrink: 0;
322
- width: 24px;
323
- height: 24px;
324
- color: $page-library-heading-icon;
325
- background-color: $page-library-heading-icon-bg;
326
- border-radius: $radius-md;
327
- }
328
-
329
- .library__continue {
330
- display: grid;
331
- gap: 16px;
332
- grid-template-columns: 1fr;
333
-
334
- @include from-br(xm) {
335
- grid-template-columns: repeat(2, 1fr);
336
- }
337
-
338
- @include from-br(sm) {
339
- gap: 24px;
340
- grid-template-columns: repeat(3, 1fr);
341
- }
342
-
343
- @include from-br(md) {
344
- grid-template-columns: repeat(4, 1fr);
345
- }
346
- }
347
-
348
- .library__grid {
349
- display: grid;
350
- gap: 16px;
351
- margin: 0;
352
- padding: 0;
353
- list-style: none;
354
- grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
355
-
356
- @include from-br(sm) {
357
- gap: 24px;
358
- grid-template-columns: repeat(4, 1fr);
359
- }
360
- }
361
-
362
- /* Карточка тянется на всю высоту ряда — иначе соседи с двухстрочным тайтлом разной высоты. */
363
- .library__grid > li {
364
- display: grid;
365
- }
366
- </style>
package/stubs/history.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { VIDEO_POOL } from './videos';
1
2
  import type { IThumbUrls } from '../types';
2
3
 
3
4
  const HOUR = 3600;
@@ -20,24 +21,26 @@ export type HistoryStubCard = {
20
21
  };
21
22
 
22
23
  /**
23
- * Ролики сняты с главной gay.cool — id, тайтлы, тумбы и каналы настоящие.
24
+ * Состояние просмотра поверх общего пула роликов — индекс в индекс с `VIDEO_POOL`.
24
25
  * `watchedAgo` — смещение в секундах от «сейчас», чтобы даты в вёрстке не устаревали.
25
26
  */
26
- const ITEMS = [
27
- { id: '445839845', title: 'Drew Sebastian Barebacks Nick Fitt: Muscle Daddy Tops Athletic Bottom on the Couch', duration: 486, thumbUrls: { webp: { '320x180': 'https://nlt01.gay.cool/a/8/d/a8dc253cad705d688508de096435a989/thumbs/320x180/3.webp', '480x270': 'https://nlt01.gay.cool/a/8/d/a8dc253cad705d688508de096435a989/thumbs/480x270/3.webp' } }, channelName: 'Icon Male', channelAvatar: 'https://nli01.joystorage.com/c/0/2/c02a3f8ef14943b5f3da537aaed46fcc.jpeg', previewUrl: 'https://nlp02.gay.cool/a/8/d/a8dc253cad705d688508de096435a989/preview/video.mp4', progress: 57, watchedAgo: 2 * HOUR },
28
- { id: '171708405', title: 'Bareback bathroom fisting with black latex gloves, rimming, piss play and anal gape', duration: 358, quality: '1080', thumbUrls: { webp: { '320x180': 'https://nlt03.gay.cool/b/7/5/b75c1bf33571126845502e2b9bb5d687/thumbs/320x180/33.webp', '480x270': 'https://nlt03.gay.cool/b/7/5/b75c1bf33571126845502e2b9bb5d687/thumbs/480x270/33.webp' } }, previewUrl: 'https://nlp02.gay.cool/b/7/5/b75c1bf33571126845502e2b9bb5d687/preview/video.mp4', progress: 74, watchedAgo: 5 * HOUR },
29
- { id: '374534215', title: 'Jake Davis Takes Bareback Anal in Blue Harness from Athletic Partner', duration: 487, thumbUrls: { webp: { '320x180': 'https://nlt02.gay.cool/2/4/6/246f577796c7c955b8df94f6454de2a5/thumbs/320x180/12.webp', '480x270': 'https://nlt02.gay.cool/2/4/6/246f577796c7c955b8df94f6454de2a5/thumbs/480x270/12.webp' } }, channelName: 'Icon Male', channelAvatar: 'https://nli01.joystorage.com/c/0/2/c02a3f8ef14943b5f3da537aaed46fcc.jpeg', previewUrl: 'https://nlp05.gay.cool/2/4/6/246f577796c7c955b8df94f6454de2a5/preview/video.mp4', progress: 91, watchedAgo: 8 * HOUR },
30
- { id: '3377550', title: 'Rego Bello and Devin Adams bareback bedroom flip: reciprocal oral then Devin tops Rego', duration: 426, thumbUrls: { webp: { '320x180': 'https://nlt01.gay.cool/3/e/b/3eb0f19f1e600a98a7f5a5e51edc4b46/thumbs/320x180/33.webp', '480x270': 'https://nlt01.gay.cool/3/e/b/3eb0f19f1e600a98a7f5a5e51edc4b46/thumbs/480x270/33.webp' } }, previewUrl: 'https://nlp05.gay.cool/3/e/b/3eb0f19f1e600a98a7f5a5e51edc4b46/preview/video.mp4', progress: 48, watchedAgo: DAY + 3 * HOUR },
31
- { id: '443170695', title: 'Johnny Rapid and Malakai White lead four-man hotel bareback with Max Lorde and Kyler Drayke', duration: 487, thumbUrls: { webp: { '320x180': 'https://nlt04.gay.cool/9/f/f/9ff067b9ea8b157ff2478667423ff9bb/thumbs/320x180/29.webp', '480x270': 'https://nlt04.gay.cool/9/f/f/9ff067b9ea8b157ff2478667423ff9bb/thumbs/480x270/29.webp' } }, channelName: 'Johnny Rapid', channelAvatar: 'https://nli01.joystorage.com/e/6/d/e6d0abd734a1a2eaef0baf7ceccfdb5c.jpeg', previewUrl: 'https://nlp01.gay.cool/9/f/f/9ff067b9ea8b157ff2478667423ff9bb/preview/video.mp4', progress: 100, watchedAgo: DAY + 7 * HOUR },
32
- { id: '175791825', title: 'Kyle Wilkinson flip-fucks bareback with a smooth skinny twink: deep oral, riding and doggy', duration: 586, thumbUrls: { webp: { '320x180': 'https://nlt05.gay.cool/9/0/f/90fd15f9305b32394f8d383113376c27/thumbs/320x180/7.webp', '480x270': 'https://nlt05.gay.cool/9/0/f/90fd15f9305b32394f8d383113376c27/thumbs/480x270/7.webp' } }, previewUrl: 'https://nlp04.gay.cool/9/0/f/90fd15f9305b32394f8d383113376c27/preview/video.mp4', progress: 23, watchedAgo: DAY + 11 * HOUR },
33
- { id: '374648505', title: 'Devon Lebron barebacks tattooed twink after rimjob in Staxus bedroom', duration: 1800, thumbUrls: { webp: { '320x180': 'https://nlt05.gay.cool/a/2/b/a2b4dce906ce8a4c107a5d559013e555/thumbs/320x180/46.webp', '480x270': 'https://nlt05.gay.cool/a/2/b/a2b4dce906ce8a4c107a5d559013e555/thumbs/480x270/46.webp' } }, previewUrl: 'https://nlp04.gay.cool/a/2/b/a2b4dce906ce8a4c107a5d559013e555/preview/video.mp4', progress: 99, watchedAgo: DAY + 16 * HOUR },
34
- { id: '307011761', title: 'Damien Nichols barebacks a tattooed twink: military amateur, deepthroat to raw anal and cumshot', duration: 419, thumbUrls: { webp: { '320x180': 'https://nlt01.gay.cool/5/a/0/5a00c5dd536d7d7ce4baef5e45930f91/thumbs/320x180/43.webp', '480x270': 'https://nlt01.gay.cool/5/a/0/5a00c5dd536d7d7ce4baef5e45930f91/thumbs/480x270/43.webp' } }, previewUrl: 'https://nlp02.gay.cool/5/a/0/5a00c5dd536d7d7ce4baef5e45930f91/preview/video.mp4', progress: 66, watchedAgo: 2 * DAY + 4 * HOUR },
35
- { id: '444068845', title: 'Dean Young Bareback Flip Fuck: Versatile Anal, Deepthroat and Rimming in the Bedroom', duration: 604, thumbUrls: { webp: { '320x180': 'https://nlt03.gay.cool/7/d/a/7daacbe876b51c7cdc50101b0d504be8/thumbs/320x180/16.webp', '480x270': 'https://nlt03.gay.cool/7/d/a/7daacbe876b51c7cdc50101b0d504be8/thumbs/480x270/16.webp' } }, previewUrl: 'https://nlp03.gay.cool/7/d/a/7daacbe876b51c7cdc50101b0d504be8/preview/video.mp4', progress: 12, watchedAgo: 2 * DAY + 9 * HOUR },
36
- { id: '374968825', title: 'Brody Fox sucks and rides daddy Cain Marko bareback', duration: 497, thumbUrls: { webp: { '320x180': 'https://nlt01.gay.cool/9/e/2/9e23f59e882e860da3ab2400d93ac73e/thumbs/320x180/10.webp', '480x270': 'https://nlt01.gay.cool/9/e/2/9e23f59e882e860da3ab2400d93ac73e/thumbs/480x270/10.webp' } }, previewUrl: 'https://nlp03.gay.cool/9/e/2/9e23f59e882e860da3ab2400d93ac73e/preview/video.mp4', progress: 88, watchedAgo: 2 * DAY + 15 * HOUR },
37
- { id: '377844995', title: 'Rick Lovatelli: blond twink dominated, deepthroats and gets barebacked by a suited bearded top', duration: 1627, thumbUrls: { webp: { '320x180': 'https://nlt05.gay.cool/f/1/b/f1b09d77a2e23f4b4696d97169614099/thumbs/320x180/25.webp', '480x270': 'https://nlt05.gay.cool/f/1/b/f1b09d77a2e23f4b4696d97169614099/thumbs/480x270/25.webp' } }, previewUrl: 'https://nlp01.gay.cool/f/1/b/f1b09d77a2e23f4b4696d97169614099/preview/video.mp4', progress: 35, watchedAgo: 4 * DAY + 6 * HOUR },
38
- { id: '375132285', title: 'Viktor Rom and Jordan Fox bareback blue-haired bottom in bedroom threesome', duration: 800, quality: '1080', thumbUrls: { webp: { '320x180': 'https://nlt03.gay.cool/5/f/b/5fbcdd007476cd0a1e9c85abc9fc0042/thumbs/320x180/1.webp', '480x270': 'https://nlt03.gay.cool/5/f/b/5fbcdd007476cd0a1e9c85abc9fc0042/thumbs/480x270/1.webp' } }, previewUrl: 'https://nlp04.gay.cool/5/f/b/5fbcdd007476cd0a1e9c85abc9fc0042/preview/video.mp4', progress: 71, watchedAgo: 4 * DAY + 12 * HOUR },
27
+ const WATCH_STATE = [
28
+ { progress: 57, watchedAgo: 2 * HOUR },
29
+ { progress: 74, watchedAgo: 5 * HOUR },
30
+ { progress: 91, watchedAgo: 8 * HOUR },
31
+ { progress: 48, watchedAgo: DAY + 3 * HOUR },
32
+ { progress: 100, watchedAgo: DAY + 7 * HOUR },
33
+ { progress: 23, watchedAgo: DAY + 11 * HOUR },
34
+ { progress: 99, watchedAgo: DAY + 16 * HOUR },
35
+ { progress: 66, watchedAgo: 2 * DAY + 4 * HOUR },
36
+ { progress: 12, watchedAgo: 2 * DAY + 9 * HOUR },
37
+ { progress: 88, watchedAgo: 2 * DAY + 15 * HOUR },
38
+ { progress: 35, watchedAgo: 4 * DAY + 6 * HOUR },
39
+ { progress: 71, watchedAgo: 4 * DAY + 12 * HOUR },
39
40
  ];
40
41
 
42
+ const ITEMS = VIDEO_POOL.map((video, index) => ({ ...video, ...WATCH_STATE[index] }));
43
+
41
44
  /** Тот же набор, отодвинутый глубже в прошлое — чтобы набралось несколько страниц. */
42
45
  const WAVES = [0, 7 * DAY, 21 * DAY, 45 * DAY, 120 * DAY];
43
46
 
@@ -0,0 +1,75 @@
1
+ import { VIDEO_POOL } from './videos';
2
+ import type { StubVideo } from './videos';
3
+
4
+ const HOUR = 3600;
5
+ const DAY = 24 * HOUR;
6
+
7
+ /** Ролик в ленте подписки: пул + флаг «новинки» и уникальный guid внутри модели. */
8
+ export type LibraryModelVideo = StubVideo & {
9
+ guid: string;
10
+ isNew: boolean;
11
+ };
12
+
13
+ export type LibraryModelStub = {
14
+ guid: string;
15
+ title: string;
16
+ primaryImageUrl: string;
17
+ country: {
18
+ name: string
19
+ title: string
20
+ };
21
+ age: number;
22
+ videosCount: number;
23
+ /** Сколько роликов вышло с прошлого захода */
24
+ newCount: number;
25
+ /** Unix timestamp (сек.) последнего добавленного ролика */
26
+ lastVideoAddedAt: number;
27
+ tags: string[];
28
+ videos: LibraryModelVideo[];
29
+ };
30
+
31
+ /** Сколько последних роликов модели показываем в ленте. */
32
+ const PREVIEW_LIMIT = 5;
33
+
34
+ /** Порядок обновлений намеренно не совпадает с алфавитным — иначе обе сортировки дают одно и то же. */
35
+ const MODELS = [
36
+ { title: 'Dakota Lovell', image: 'Dakota_Lovell.png', country: { name: 'us', title: 'USA' }, age: 22, videosCount: 41, newCount: 3, tags: ['Twink', 'Jock', 'Versatile'], updatedAgo: 6 * HOUR, videoOffset: 3 },
37
+ { title: 'Jax Thirio', image: 'Jax_Thirio.png', country: { name: 'ca', title: 'Canada' }, age: 38, videosCount: 17, newCount: 2, tags: ['Muscle', 'Daddy', 'Top'], updatedAgo: 2 * DAY, videoOffset: 9 },
38
+ { title: 'Austin Young', image: 'Austin_Young.png', country: { name: 'us', title: 'USA' }, age: 24, videosCount: 75, newCount: 1, tags: ['Twink', 'Smooth', 'Bottom'], updatedAgo: 9 * DAY, videoOffset: 0 },
39
+ { title: 'Devin Franco', image: 'Devin_Franco.png', country: { name: 'us', title: 'USA' }, age: 33, videosCount: 24, newCount: 0, tags: ['Muscle', 'Tattooed', 'Bottom'], updatedAgo: 21 * DAY, videoOffset: 6 },
40
+ ];
41
+
42
+ /**
43
+ * Подписки пользователя на моделей. Ручки под них пока нет — вёрстка живёт на заглушке.
44
+ * Возраст ролика считаем от «сейчас», чтобы даты в разметке не устаревали.
45
+ */
46
+ export function getLibraryModelsStub(): LibraryModelStub[] {
47
+ const now = Math.floor(Date.now() / 1000);
48
+
49
+ return MODELS.map((model) => {
50
+ const slug = model.title.toLowerCase().replace(/\s+/g, '-');
51
+
52
+ const videos = Array.from({ length: PREVIEW_LIMIT }, (_, index) => {
53
+ const video = VIDEO_POOL[(model.videoOffset + index) % VIDEO_POOL.length];
54
+
55
+ return {
56
+ ...video,
57
+ guid: `${slug}-${video.id}`,
58
+ isNew: index < model.newCount,
59
+ };
60
+ });
61
+
62
+ return {
63
+ guid: slug,
64
+ title: model.title,
65
+ primaryImageUrl: `/img/models/${model.image}`,
66
+ country: model.country,
67
+ age: model.age,
68
+ videosCount: model.videosCount,
69
+ newCount: model.newCount,
70
+ lastVideoAddedAt: now - model.updatedAgo,
71
+ tags: model.tags,
72
+ videos,
73
+ };
74
+ });
75
+ }
@@ -0,0 +1,29 @@
1
+ import type { IThumbUrls } from '../types';
2
+
3
+ /** Базовый ролик заглушек: id, тайтлы и тумбы сняты с главной gay.cool. */
4
+ export type StubVideo = {
5
+ id: string;
6
+ title: string;
7
+ duration: number;
8
+ quality?: string;
9
+ thumbUrls: IThumbUrls;
10
+ previewUrl: string;
11
+ channelName?: string;
12
+ channelAvatar?: string;
13
+ };
14
+
15
+ /** Общий пул роликов для всех заглушек — порядок фиксирован, на него завязаны наборы-надстройки. */
16
+ export const VIDEO_POOL: StubVideo[] = [
17
+ { id: '445839845', title: 'Drew Sebastian Barebacks Nick Fitt: Muscle Daddy Tops Athletic Bottom on the Couch', duration: 486, thumbUrls: { webp: { '320x180': 'https://nlt01.gay.cool/a/8/d/a8dc253cad705d688508de096435a989/thumbs/320x180/3.webp', '480x270': 'https://nlt01.gay.cool/a/8/d/a8dc253cad705d688508de096435a989/thumbs/480x270/3.webp' } }, channelName: 'Icon Male', channelAvatar: 'https://nli01.joystorage.com/c/0/2/c02a3f8ef14943b5f3da537aaed46fcc.jpeg', previewUrl: 'https://nlp02.gay.cool/a/8/d/a8dc253cad705d688508de096435a989/preview/video.mp4' },
18
+ { id: '171708405', title: 'Bareback bathroom fisting with black latex gloves, rimming, piss play and anal gape', duration: 358, quality: '1080', thumbUrls: { webp: { '320x180': 'https://nlt03.gay.cool/b/7/5/b75c1bf33571126845502e2b9bb5d687/thumbs/320x180/33.webp', '480x270': 'https://nlt03.gay.cool/b/7/5/b75c1bf33571126845502e2b9bb5d687/thumbs/480x270/33.webp' } }, previewUrl: 'https://nlp02.gay.cool/b/7/5/b75c1bf33571126845502e2b9bb5d687/preview/video.mp4' },
19
+ { id: '374534215', title: 'Jake Davis Takes Bareback Anal in Blue Harness from Athletic Partner', duration: 487, thumbUrls: { webp: { '320x180': 'https://nlt02.gay.cool/2/4/6/246f577796c7c955b8df94f6454de2a5/thumbs/320x180/12.webp', '480x270': 'https://nlt02.gay.cool/2/4/6/246f577796c7c955b8df94f6454de2a5/thumbs/480x270/12.webp' } }, channelName: 'Icon Male', channelAvatar: 'https://nli01.joystorage.com/c/0/2/c02a3f8ef14943b5f3da537aaed46fcc.jpeg', previewUrl: 'https://nlp05.gay.cool/2/4/6/246f577796c7c955b8df94f6454de2a5/preview/video.mp4' },
20
+ { id: '3377550', title: 'Rego Bello and Devin Adams bareback bedroom flip: reciprocal oral then Devin tops Rego', duration: 426, thumbUrls: { webp: { '320x180': 'https://nlt01.gay.cool/3/e/b/3eb0f19f1e600a98a7f5a5e51edc4b46/thumbs/320x180/33.webp', '480x270': 'https://nlt01.gay.cool/3/e/b/3eb0f19f1e600a98a7f5a5e51edc4b46/thumbs/480x270/33.webp' } }, previewUrl: 'https://nlp05.gay.cool/3/e/b/3eb0f19f1e600a98a7f5a5e51edc4b46/preview/video.mp4' },
21
+ { id: '443170695', title: 'Johnny Rapid and Malakai White lead four-man hotel bareback with Max Lorde and Kyler Drayke', duration: 487, thumbUrls: { webp: { '320x180': 'https://nlt04.gay.cool/9/f/f/9ff067b9ea8b157ff2478667423ff9bb/thumbs/320x180/29.webp', '480x270': 'https://nlt04.gay.cool/9/f/f/9ff067b9ea8b157ff2478667423ff9bb/thumbs/480x270/29.webp' } }, channelName: 'Johnny Rapid', channelAvatar: 'https://nli01.joystorage.com/e/6/d/e6d0abd734a1a2eaef0baf7ceccfdb5c.jpeg', previewUrl: 'https://nlp01.gay.cool/9/f/f/9ff067b9ea8b157ff2478667423ff9bb/preview/video.mp4' },
22
+ { id: '175791825', title: 'Kyle Wilkinson flip-fucks bareback with a smooth skinny twink: deep oral, riding and doggy', duration: 586, thumbUrls: { webp: { '320x180': 'https://nlt05.gay.cool/9/0/f/90fd15f9305b32394f8d383113376c27/thumbs/320x180/7.webp', '480x270': 'https://nlt05.gay.cool/9/0/f/90fd15f9305b32394f8d383113376c27/thumbs/480x270/7.webp' } }, previewUrl: 'https://nlp04.gay.cool/9/0/f/90fd15f9305b32394f8d383113376c27/preview/video.mp4' },
23
+ { id: '374648505', title: 'Devon Lebron barebacks tattooed twink after rimjob in Staxus bedroom', duration: 1800, thumbUrls: { webp: { '320x180': 'https://nlt05.gay.cool/a/2/b/a2b4dce906ce8a4c107a5d559013e555/thumbs/320x180/46.webp', '480x270': 'https://nlt05.gay.cool/a/2/b/a2b4dce906ce8a4c107a5d559013e555/thumbs/480x270/46.webp' } }, previewUrl: 'https://nlp04.gay.cool/a/2/b/a2b4dce906ce8a4c107a5d559013e555/preview/video.mp4' },
24
+ { id: '307011761', title: 'Damien Nichols barebacks a tattooed twink: military amateur, deepthroat to raw anal and cumshot', duration: 419, thumbUrls: { webp: { '320x180': 'https://nlt01.gay.cool/5/a/0/5a00c5dd536d7d7ce4baef5e45930f91/thumbs/320x180/43.webp', '480x270': 'https://nlt01.gay.cool/5/a/0/5a00c5dd536d7d7ce4baef5e45930f91/thumbs/480x270/43.webp' } }, previewUrl: 'https://nlp02.gay.cool/5/a/0/5a00c5dd536d7d7ce4baef5e45930f91/preview/video.mp4' },
25
+ { id: '444068845', title: 'Dean Young Bareback Flip Fuck: Versatile Anal, Deepthroat and Rimming in the Bedroom', duration: 604, thumbUrls: { webp: { '320x180': 'https://nlt03.gay.cool/7/d/a/7daacbe876b51c7cdc50101b0d504be8/thumbs/320x180/16.webp', '480x270': 'https://nlt03.gay.cool/7/d/a/7daacbe876b51c7cdc50101b0d504be8/thumbs/480x270/16.webp' } }, previewUrl: 'https://nlp03.gay.cool/7/d/a/7daacbe876b51c7cdc50101b0d504be8/preview/video.mp4' },
26
+ { id: '374968825', title: 'Brody Fox sucks and rides daddy Cain Marko bareback', duration: 497, thumbUrls: { webp: { '320x180': 'https://nlt01.gay.cool/9/e/2/9e23f59e882e860da3ab2400d93ac73e/thumbs/320x180/10.webp', '480x270': 'https://nlt01.gay.cool/9/e/2/9e23f59e882e860da3ab2400d93ac73e/thumbs/480x270/10.webp' } }, previewUrl: 'https://nlp03.gay.cool/9/e/2/9e23f59e882e860da3ab2400d93ac73e/preview/video.mp4' },
27
+ { id: '377844995', title: 'Rick Lovatelli: blond twink dominated, deepthroats and gets barebacked by a suited bearded top', duration: 1627, thumbUrls: { webp: { '320x180': 'https://nlt05.gay.cool/f/1/b/f1b09d77a2e23f4b4696d97169614099/thumbs/320x180/25.webp', '480x270': 'https://nlt05.gay.cool/f/1/b/f1b09d77a2e23f4b4696d97169614099/thumbs/480x270/25.webp' } }, previewUrl: 'https://nlp01.gay.cool/f/1/b/f1b09d77a2e23f4b4696d97169614099/preview/video.mp4' },
28
+ { id: '375132285', title: 'Viktor Rom and Jordan Fox bareback blue-haired bottom in bedroom threesome', duration: 800, quality: '1080', thumbUrls: { webp: { '320x180': 'https://nlt03.gay.cool/5/f/b/5fbcdd007476cd0a1e9c85abc9fc0042/thumbs/320x180/1.webp', '480x270': 'https://nlt03.gay.cool/5/f/b/5fbcdd007476cd0a1e9c85abc9fc0042/thumbs/480x270/1.webp' } }, previewUrl: 'https://nlp04.gay.cool/5/f/b/5fbcdd007476cd0a1e9c85abc9fc0042/preview/video.mp4' },
29
+ ];
@@ -1,139 +0,0 @@
1
- <!-- Видео-предпросмотр (автоплей) поверх постера карточки видео + индикатор загрузки -->
2
- <template>
3
- <span class="card-preview">
4
- <video
5
- ref="videoRef"
6
- :src="url"
7
- :class="{'--playing': isPlaying}"
8
- class="card-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-preview__loader"
23
- />
24
- </span>
25
- </template>
26
-
27
- <script setup lang="ts">
28
- defineProps<{
29
- url: string
30
- }>();
31
-
32
- /** Стартовая ширина полосы: видна сразу по ховеру/касанию, не дожидаясь первого скачанного куска файла. */
33
- const MIN_PROGRESS = 0.05;
34
- /** Докуда полоса подползает сама, пока нет данных о реальной загрузке. */
35
- const CREEP_LIMIT = 0.45;
36
- const CREEP_INTERVAL = 150;
37
- const CREEP_STEP = 0.18;
38
-
39
- const videoRef = ref<HTMLVideoElement | null>(null);
40
- const isPlaying = ref(false);
41
- const isLoaded = ref(false);
42
- const progress = ref(MIN_PROGRESS);
43
-
44
- let creepTimer: ReturnType<typeof setInterval> | null = null;
45
-
46
- function stopCreep() {
47
- if (creepTimer) clearInterval(creepTimer);
48
- creepTimer = null;
49
- }
50
-
51
- /** Имитация ожидания: пока идёт коннект к CDN и реального прогресса нет, полоса замедляясь подползает к CREEP_LIMIT. */
52
- function startCreep() {
53
- creepTimer = setInterval(() => {
54
- const next = progress.value + (CREEP_LIMIT - progress.value) * CREEP_STEP;
55
-
56
- if (CREEP_LIMIT - next < CREEP_STEP / 100) {
57
- stopCreep();
58
- return;
59
- }
60
-
61
- progress.value = next;
62
- }, CREEP_INTERVAL);
63
- }
64
-
65
- /** Доля скачанного файла превью: правый край последнего буферизованного отрезка к общей длительности. */
66
- function updateProgress() {
67
- const video = videoRef.value;
68
- if (!video || !video.buffered.length || !Number.isFinite(video.duration)) return;
69
-
70
- const loaded = video.buffered.end(video.buffered.length - 1) / video.duration;
71
- if (loaded > 0) stopCreep();
72
-
73
- progress.value = Math.max(loaded, progress.value);
74
- if (loaded >= 1) isLoaded.value = true;
75
- }
76
-
77
- function onPlaying() {
78
- stopCreep();
79
- isPlaying.value = true;
80
- isLoaded.value = true;
81
- }
82
-
83
- /** Буферизация на середине: возвращаем постер и полосу, она продолжит с реально скачанного места. */
84
- function onWaiting() {
85
- isPlaying.value = false;
86
- isLoaded.value = false;
87
- }
88
-
89
- onMounted(() => {
90
- startCreep();
91
-
92
- const video = videoRef.value;
93
- if (!video) return;
94
-
95
- video.muted = true;
96
- video.play().catch(() => {
97
- isPlaying.value = false;
98
- });
99
- });
100
-
101
- onBeforeUnmount(stopCreep);
102
- </script>
103
-
104
- <style lang="scss">
105
- $loader-height: 4px;
106
-
107
- .card-preview {
108
- position: absolute;
109
- inset: 0;
110
- z-index: $z-index-plus;
111
- }
112
-
113
- .card-preview__video {
114
- width: 100%;
115
- height: 100%;
116
- object-fit: cover;
117
- opacity: 0;
118
- }
119
-
120
- /* Пока автоплей не стартовал, браузер рисует поверх видео нативную кнопку плей — держим элемент невидимым */
121
- .card-preview__video.--playing {
122
- opacity: 1;
123
- }
124
-
125
- .card-preview__loader {
126
- position: absolute;
127
- inset: 0 0 auto;
128
- height: $loader-height;
129
- background: $card-preview-loader-bg;
130
- transform: scaleX(var(--preview-progress, 0));
131
- transform-origin: left center;
132
- transition: var(--transition) transform, var(--transition) var(--default-transition-duration) opacity;
133
- }
134
-
135
- /* Догрузилось или пошло воспроизведение — полоса доезжает до конца и только потом гаснет */
136
- .card-preview__loader.--done {
137
- opacity: 0;
138
- }
139
- </style>